Compare commits

...

19 Commits

Author SHA1 Message Date
HugoCasa
24bf1f1493 nit 2025-04-11 18:28:15 +02:00
HugoCasa
a34ed9a24d typo 2025-04-11 17:22:57 +02:00
HugoCasa
fc261b4ac1 nit 2025-04-11 15:50:25 +02:00
HugoCasa
ed6627032e fix sqlx 2025-04-11 15:33:11 +02:00
HugoCasa
577c748b55 Merge remote-tracking branch 'origin/main' into hc/signed-s3-objects 2025-04-11 15:07:56 +02:00
HugoCasa
62b0925db9 b 2025-04-11 15:00:16 +02:00
HugoCasa
5409b280c3 Merge remote-tracking branch 'origin/main' into hc/signed-s3-objects 2025-04-11 09:57:39 +02:00
HugoCasa
dea74c0846 fix build 2025-04-10 23:06:09 +02:00
HugoCasa
31d15d6d33 Update frontend/src/lib/components/apps/editor/settingsPanel/InputsSpecEditor.svelte
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-04-10 23:04:20 +02:00
HugoCasa
5c0da7f7bd Update frontend/src/lib/components/apps/editor/settingsPanel/InputsSpecEditor.svelte
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-04-10 23:04:13 +02:00
HugoCasa
0c5abc1f2f presigned 2025-04-10 22:37:19 +02:00
HugoCasa
c8cc52d840 Merge remote-tracking branch 'origin/main' into hc/signed-s3-objects 2025-04-10 11:38:32 +02:00
HugoCasa
4b9cc2ec28 fix build 2025-04-09 18:48:21 +02:00
HugoCasa
091a4717f4 fix build 2025-04-09 18:25:54 +02:00
HugoCasa
cb7c83c374 nti 2025-04-09 18:11:15 +02:00
HugoCasa
51eb0be80a Update python-client/wmill/wmill/client.py
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
2025-04-09 18:10:14 +02:00
HugoCasa
6fcdf2af2e fix build 2025-04-09 18:09:52 +02:00
HugoCasa
38d8cf842a Update backend/windmill-api/openapi.yaml
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-04-09 18:07:20 +02:00
HugoCasa
0eff6b139d feat: accept signed s3 objects for s3 file keys in apps + sign endpoint and helpers 2025-04-09 18:02:03 +02:00
34 changed files with 883 additions and 473 deletions

View File

@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_as_completed_job \n WHERE workspace_id = $2 \n AND (job_kind = 'appscript' OR job_kind = 'preview')\n AND created_by = 'anonymous' \n AND started_at > now() - interval '3 hours'\n AND script_path LIKE $3 || '/%' \n AND result @> ('{\"s3\":\"' || $1 || '\"}')::jsonb \n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "43aa468aac174529a74e6108af55a383f8a20e98b8c502929f4dc5041a55e72f"
}

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_as_completed_job\n WHERE workspace_id = $2\n AND (job_kind = 'appscript' OR job_kind = 'preview')\n AND created_by = 'anonymous'\n AND started_at > now() - interval '3 hours'\n AND script_path LIKE $3 || '/%'\n AND result @> ('{\"s3\":\"' || $1 || '\"}')::jsonb\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ec9980f80a8bfa4b09225035e8a1f78d7d61fbf83dda9b39bb22e0f9584d221b"
}

View File

@@ -6307,6 +6307,38 @@ paths:
schema:
type: boolean
/w/{workspace}/apps/sign_s3_objects:
post:
summary: sign s3 objects, to be used by anonymous users in public apps
operationId: signS3Objects
tags:
- app
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: s3 objects to sign
required: true
content:
application/json:
schema:
type: object
properties:
s3_objects:
type: array
items:
$ref: "#/components/schemas/S3Object"
required:
- s3_objects
responses:
"200":
description: signed s3 objects
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/S3Object"
/w/{workspace}/apps_u/execute_component/{path}:
post:
summary: executeComponent
@@ -16455,3 +16487,17 @@ components:
required:
- account_id
- installation_id
S3Object:
type: object
properties:
s3:
type: string
filename:
type: string
storage:
type: string
presigned:
type: string
required:
- s3

View File

@@ -20,8 +20,7 @@ use crate::{
use crate::{
job_helpers_ee::{
download_s3_file_internal, get_random_file_name, get_s3_resource,
get_workspace_s3_resource, load_image_preview_internal, upload_file_from_req,
DownloadFileQuery, LoadImagePreviewQuery,
get_workspace_s3_resource, upload_file_from_req, DownloadFileQuery,
},
users::fetch_api_authed_from_permissioned_as,
};
@@ -51,7 +50,6 @@ use sqlx::{types::Uuid, FromRow};
use std::str;
use windmill_audit::audit_ee::audit_log;
use windmill_audit::ActionKind;
use windmill_common::variables::encrypt;
use windmill_common::{
apps::{AppScriptId, ListAppQuery},
cache::{self, future::FutureCachedExt},
@@ -63,16 +61,24 @@ use windmill_common::{
http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin,
Pagination, StripPath,
},
variables::{build_crypt, build_crypt_with_key_suffix},
variables::{build_crypt, build_crypt_with_key_suffix, encrypt},
worker::{to_raw_value, CLOUD_HOSTED},
HUB_BASE_URL,
};
#[cfg(feature = "parquet")]
use windmill_common::{jwt, s3_helpers::build_object_store_client};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel};
#[cfg(feature = "parquet")]
use hmac::Mac;
#[cfg(feature = "parquet")]
use windmill_common::{
jwt,
oauth2::HmacSha256,
s3_helpers::{build_object_store_client, S3Object},
variables::get_workspace_key,
};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_apps))
@@ -94,6 +100,7 @@ pub fn workspaced_service() -> Router {
get(list_paths_from_workspace_runnable),
)
.route("/custom_path_exists/*custom_path", get(custom_path_exists))
.route("/sign_s3_objects", post(sign_s3_objects))
}
pub fn unauthed_service() -> Router {
@@ -102,10 +109,6 @@ pub fn unauthed_service() -> Router {
.route("/upload_s3_file/*path", post(upload_s3_file_from_app))
.route("/delete_s3_file", delete(delete_s3_file_from_app))
.route("/download_s3_file/*path", get(download_s3_file_from_app))
.route(
"/load_image_preview/*path",
get(load_s3_file_image_preview_from_app),
)
.route("/public_app/:secret", get(get_public_app_by_secret))
.route("/public_resource/*path", get(get_public_resource))
}
@@ -232,7 +235,8 @@ pub struct S3Input {
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct S3Key {
s3_path: String,
resource: String,
#[serde(skip_serializing_if = "Option::is_none")]
storage: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
@@ -1554,15 +1558,97 @@ struct UploadFileToS3Query {
#[cfg(feature = "parquet")]
#[derive(Serialize, Deserialize)]
struct DeleteTokenClaims {
struct S3DeleteTokenClaims {
file_key: String,
on_behalf_of_email: String,
permissioned_as: String,
username: String,
s3_resource_path: Option<String>,
workspace: String,
pub exp: usize,
}
#[cfg(feature = "parquet")]
#[derive(Deserialize)]
struct S3TokenRequestBody {
s3_objects: Vec<S3Object>,
}
#[cfg(feature = "parquet")]
async fn sign_s3_objects(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(body): Json<S3TokenRequestBody>,
) -> Result<Json<Vec<S3Object>>> {
let workspace_key = get_workspace_key(&w_id, &db).await?;
let futures = body.s3_objects.into_iter().map(|s3_object| async {
let exp = (chrono::Utc::now() + chrono::Duration::hours(12)).timestamp();
let mut message = format!("file_key={}&exp={}", s3_object.s3.clone(), exp);
if let Some(ref storage) = s3_object.storage {
message = format!("{}&storage={}", message, storage);
}
let mut max = HmacSha256::new_from_slice(workspace_key.as_bytes())
.map_err(|err| Error::internal_err(format!("Failed to create hmac: {}", err)))?;
max.update(message.as_bytes());
let result = max.finalize();
let signature = hex::encode(result.into_bytes());
let presigned = format!("exp={}&sig={}", exp, signature);
Ok::<_, Error>(S3Object { presigned: Some(presigned), ..s3_object })
});
let signed_s3_objects = futures::future::try_join_all(futures).await?;
Ok(Json(signed_s3_objects))
}
#[cfg(feature = "parquet")]
async fn validate_s3_signature(file_query: &AppS3FileQuery, w_id: &str, db: &DB) -> Result<()> {
let workspace_key = get_workspace_key(w_id, &db).await?;
let Some(exp) = file_query
.exp
.as_ref()
.map(|e| e.parse::<i64>().unwrap_or_default())
else {
return Err(Error::BadRequest("Missing exp".to_string()));
};
let Some(ref sig) = file_query.sig else {
return Err(Error::BadRequest("Missing signature".to_string()));
};
let mut message = format!("file_key={}&exp={}", file_query.s3, exp);
if let Some(ref storage) = file_query.storage {
message = format!("{}&storage={}", message, storage);
}
let mut mac = HmacSha256::new_from_slice(workspace_key.as_bytes())
.map_err(|err| Error::internal_err(format!("Failed to create hmac: {}", err)))?;
mac.update(message.as_bytes());
let sig_bytes = hex::decode(sig)?;
mac.verify_slice(&sig_bytes)
.map_err(|err| Error::BadRequest(format!("Invalid signature: {}", err)))?;
if exp < chrono::Utc::now().timestamp() {
return Err(Error::BadRequest("Signature expired".to_string()));
}
Ok(())
}
#[cfg(not(feature = "parquet"))]
async fn sign_s3_objects() -> Result<()> {
return Err(Error::BadRequest(
"This endpoint requires the parquet feature to be enabled".to_string(),
));
}
#[cfg(feature = "parquet")]
#[derive(Serialize)]
struct AppUploadFileResponse {
@@ -1817,13 +1903,14 @@ async fn upload_s3_file_from_app(
upload_file_from_req(s3_client, &file_key, request, options).await?;
let delete_token = jwt::encode_with_internal_secret(DeleteTokenClaims {
let delete_token = jwt::encode_with_internal_secret(S3DeleteTokenClaims {
file_key: file_key.clone(),
on_behalf_of_email,
permissioned_as,
username,
s3_resource_path: query.s3_resource_path,
exp: (chrono::Utc::now() + chrono::Duration::seconds(3600 * 24)).timestamp() as usize,
workspace: w_id.clone(),
exp: (chrono::Utc::now() + chrono::Duration::hours(12)).timestamp() as usize,
})
.await?;
@@ -1843,14 +1930,19 @@ async fn delete_s3_file_from_app(
Path(w_id): Path<String>,
Query(query): Query<DeleteS3FileQuery>,
) -> Result<()> {
let DeleteTokenClaims {
let S3DeleteTokenClaims {
file_key,
on_behalf_of_email,
permissioned_as,
username,
s3_resource_path,
workspace,
..
} = jwt::decode_with_internal_secret::<DeleteTokenClaims>(&query.delete_token).await?;
} = jwt::decode_with_internal_secret::<S3DeleteTokenClaims>(&query.delete_token).await?;
if workspace != w_id {
return Err(Error::BadRequest("Invalid workspace".to_string()));
}
let on_behalf_authed = fetch_api_authed_from_permissioned_as(
permissioned_as,
@@ -1958,7 +2050,7 @@ async fn get_on_behalf_authed_from_app(
async fn check_if_allowed_to_access_s3_file_from_app(
db: &DB,
opt_authed: &Option<ApiAuthed>,
file_key: &str,
file_query: &AppS3FileQuery,
w_id: &str,
path: &str,
policy: &Policy,
@@ -1966,40 +2058,59 @@ async fn check_if_allowed_to_access_s3_file_from_app(
// if anonymous, check that the file was the result of an app script ran by an anonymous user in the last 3 hours
// otherwise, if logged in, allow any file (TODO: change that when we implement better s3 policy)
let allowed = opt_authed.is_some()
|| sqlx::query_scalar!(
r#"SELECT EXISTS (
SELECT 1 FROM v2_as_completed_job
WHERE workspace_id = $2
AND (job_kind = 'appscript' OR job_kind = 'preview')
AND created_by = 'anonymous'
AND started_at > now() - interval '3 hours'
AND script_path LIKE $3 || '/%'
AND result @> ('{"s3":"' || $1 || '"}')::jsonb
)"#,
file_key,
w_id,
path,
)
.fetch_one(db)
.await?
.unwrap_or(false)
// check if the file is allowed by the allowed_s3_keys policy
|| policy.allowed_s3_keys.as_ref().unwrap().iter().any(|key| key.s3_path == file_key);
if !allowed {
Err(Error::BadRequest("File restricted".to_string()))
} else {
if file_query.sig.is_some() {
validate_s3_signature(file_query, w_id, &db).await
} else if opt_authed.is_some() {
Ok(())
} else {
let allowed = policy
.allowed_s3_keys
.as_ref()
.unwrap()
.iter()
.any(|key| key.s3_path == file_query.s3 && key.storage == file_query.storage)
|| {
sqlx::query_scalar!(
r#"SELECT EXISTS (
SELECT 1 FROM v2_as_completed_job
WHERE workspace_id = $2
AND (job_kind = 'appscript' OR job_kind = 'preview')
AND created_by = 'anonymous'
AND started_at > now() - interval '3 hours'
AND script_path LIKE $3 || '/%'
AND result @> ('{"s3":"' || $1 || '"}')::jsonb
)"#,
file_query.s3,
w_id,
path,
)
.fetch_one(db)
.await?
.unwrap_or(false)
};
if !allowed {
Err(Error::BadRequest("File restricted".to_string()))
} else {
Ok(())
}
}
}
#[cfg(feature = "parquet")]
#[derive(Deserialize)]
pub struct DownloadFileQueryWithForceViewerAllowedS3Keys {
#[derive(Deserialize, Debug)]
struct AppS3FileQuery {
s3: String,
storage: Option<String>,
sig: Option<String>,
exp: Option<String>,
}
#[cfg(feature = "parquet")]
#[derive(Deserialize, Debug)]
struct AppS3FileQueryWithForceViewerAllowedS3Keys {
#[serde(flatten)]
pub file_query: DownloadFileQuery,
pub file_query: AppS3FileQuery,
pub force_viewer_allowed_s3_keys: Option<String>,
}
@@ -2008,7 +2119,7 @@ async fn download_s3_file_from_app(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<DownloadFileQueryWithForceViewerAllowedS3Keys>,
Query(query): Query<AppS3FileQueryWithForceViewerAllowedS3Keys>,
) -> Result<Response> {
let path = path.to_path();
@@ -2027,46 +2138,26 @@ async fn download_s3_file_from_app(
check_if_allowed_to_access_s3_file_from_app(
&db,
&opt_authed,
&query.file_query.file_key,
&query.file_query,
&w_id,
&path,
&policy,
)
.await?;
download_s3_file_internal(on_behalf_authed, &db, None, "", &w_id, query.file_query).await
}
#[cfg(not(feature = "parquet"))]
async fn load_s3_file_image_preview_from_app() -> Result<()> {
return Err(Error::BadRequest(
"This endpoint requires the parquet feature to be enabled".to_string(),
));
}
#[cfg(feature = "parquet")]
async fn load_s3_file_image_preview_from_app(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<LoadImagePreviewQuery>,
) -> Result<Response> {
let path = path.to_path();
let (on_behalf_authed, policy) =
get_on_behalf_authed_from_app(&db, &path, &w_id, &opt_authed, None).await?;
check_if_allowed_to_access_s3_file_from_app(
download_s3_file_internal(
on_behalf_authed,
&db,
&opt_authed,
&query.file_key,
None,
"",
&w_id,
&path,
&policy,
DownloadFileQuery {
file_key: query.file_query.s3,
s3_resource_path: None,
storage: query.file_query.storage,
},
)
.await?;
load_image_preview_internal(on_behalf_authed, &db, "", &w_id, query).await
.await
}
fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {

View File

@@ -30,12 +30,20 @@ pub struct UploadFileResponse {
#[derive(Deserialize)]
pub struct LoadImagePreviewQuery {
#[allow(dead_code)]
pub file_key: String,
#[allow(dead_code)]
pub storage: Option<String>,
}
#[derive(Deserialize)]
pub struct DownloadFileQuery {
#[allow(dead_code)]
pub file_key: String,
#[allow(dead_code)]
pub storage: Option<String>,
#[allow(dead_code)]
pub s3_resource_path: Option<String>,
}
pub fn workspaced_service() -> Router {
@@ -111,16 +119,3 @@ pub async fn download_s3_file_internal(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}
#[cfg(feature = "parquet")]
pub async fn load_image_preview_internal(
_authed: ApiAuthed,
_db: &DB,
_token: &str,
_w_id: &str,
_query: LoadImagePreviewQuery,
) -> error::Result<Response> {
Err(error::Error::internal_err(
"Not implemented in Windmill's Open Source repository".to_string(),
))
}

View File

@@ -111,13 +111,15 @@ pub struct S3AwsOidcResource {
pub audience: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct S3Object {
pub s3: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presigned: Option<String>,
}
#[cfg(feature = "parquet")]

View File

@@ -903,7 +903,7 @@ pub async fn get_cached_resource_value_if_valid(
S3Object {
s3: s3_file_key.clone(),
storage: resource.storage.clone(),
filename: None,
..Default::default()
},
)
.await;

View File

@@ -79,7 +79,8 @@
"y-monaco": "^0.1.4",
"y-websocket": "^1.5.4",
"yaml": "^2.3.4",
"yjs": "^13.6.7"
"yjs": "^13.6.7",
"zod": "^3.24.2"
},
"devDependencies": {
"@floating-ui/core": "^1.3.1",
@@ -11085,9 +11086,9 @@
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -152,7 +152,8 @@
"y-monaco": "^0.1.4",
"y-websocket": "^1.5.4",
"yaml": "^2.3.4",
"yjs": "^13.6.7"
"yjs": "^13.6.7",
"zod": "^3.24.2"
},
"peerDependencies": {
"svelte": "^4.0.0"

View File

@@ -765,18 +765,6 @@
}}
/>
{/await}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(value)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
{:else}
<FileUpload
{appPath}
@@ -799,6 +787,18 @@
initialValue={value}
/>
{/if}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(value)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
</div>
{:else if inputCat == 'object' || inputCat == 'resource-object' || isListJson}
{#if oneOf && oneOf.length >= 2}

View File

@@ -772,24 +772,30 @@
<img
alt="preview rendered"
class="w-auto h-full"
src={`/api/w/${workspaceId}/${
src="{`/api/w/${workspaceId}/${
appPath
? 'apps_u/load_image_preview/' + appPath
? 'apps_u/download_s3_file/' + appPath
: 'job_helpers/load_image_preview'
}?file_key=${encodeURIComponent(result.s3)}` +
(result.storage ? `&storage=${result.storage}` : '')}
}?${appPath ? 's3' : 'file_key'}=${encodeURIComponent(result.s3)}` +
(result.storage ? `&storage=${result.storage}` : '')}{appPath &&
result.presigned
? `&${result.presigned}`
: ''}"
/>
</div>
{:else if result?.s3?.endsWith('.pdf')}
<div class="h-96 mt-2 border">
<PdfViewer
allowFullscreen
source={`/api/w/${workspaceId}/${
source="{`/api/w/${workspaceId}/${
appPath
? 'apps_u/load_image_preview/' + appPath
? 'apps_u/download_s3_file/' + appPath
: 'job_helpers/load_image_preview'
}?file_key=${encodeURIComponent(result.s3)}` +
(result.storage ? `&storage=${result.storage}` : '')}
}?${appPath ? 's3' : 'file_key'}=${encodeURIComponent(result.s3)}` +
(result.storage ? `&storage=${result.storage}` : '')}{appPath &&
result.presigned
? `&${result.presigned}`
: ''}"
/>
</div>
{/if}

View File

@@ -66,18 +66,6 @@
bind:value
/>
{/await}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(value)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
{:else}
<FileUpload
allowMultiple={false}
@@ -95,4 +83,16 @@
defaultValue={value?.s3}
/>
{/if}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(value)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
</div>

View File

@@ -11,7 +11,7 @@
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import { defaultIfEmptyString } from '$lib/utils'
import { userStore } from '$lib/stores'
import { computeS3ImageViewerPolicy } from '../../editor/appUtilsS3'
import { computeS3ImageViewerPolicy, isPartialS3Object } from '../../editor/appUtilsS3'
export let id: string
export let configuration: RichConfigurations
@@ -22,7 +22,7 @@
if (!isEditor) {
return undefined
}
const policy = computeS3ImageViewerPolicy(configuration, $app)
const policy = computeS3ImageViewerPolicy(configuration)
return policy
}
@@ -46,25 +46,36 @@
let imageUrl: string | undefined = undefined
async function getS3Image(source: string | undefined) {
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('file_key', source)
params.append('s3', source)
if (storage) {
params.append('storage', storage)
}
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()}`
return `/api/w/${workspace}/apps_u/download_s3_file/${appPathOrUser}?${params.toString()}${presigned ? `&${presigned}` : ''}`
}
async function loadImage() {
if (
if (isPartialS3Object(resolvedConfig.source)) {
imageUrl = await getS3Image(
resolvedConfig.source.s3,
resolvedConfig.source.storage,
resolvedConfig.source.presigned
)
} 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://')
) {

View File

@@ -352,10 +352,10 @@
.filter((x) => (x.data as AppComponent).type === 'imagecomponent')
.map((x) => {
const c = x.data as AppComponent
const config = c.configuration as any
return computeS3ImageViewerPolicy(config, $app)
const config = c.configuration
return computeS3ImageViewerPolicy(config)
})
.filter(Boolean) as { s3_path?: string | undefined; resource?: string | undefined }[]
.filter(Boolean) as { s3_path: string; storage?: string | undefined }[]
policy.allowed_s3_keys = s3FileKeys
}

View File

@@ -1,7 +1,7 @@
import type { AppInput, EvalInputV2 } from '../inputType'
import type { App } from '../types'
import type { App, RichConfigurations } from '../types'
import { collectOneOfFields } from './appUtils'
import { z } from 'zod'
function filenameExprToRegex(template: string) {
const filenameEscaped = template.replaceAll('${file.name}', '<file_name>') // replace filename with placeholder
const escapedTemplate = filenameEscaped
@@ -51,12 +51,12 @@ export function computeS3FileInputPolicy(s3Config: any, app: App) {
? resourceInput.value
? [removeResourcePrefix(resourceInput.value)]
: []
: collectOneOfFields(
: (collectOneOfFields(
{
s3_resource: resourceInput
},
app
).s3_resource?.map((s) => removeResourcePrefix(s)) ?? []
).s3_resource?.map((s) => removeResourcePrefix(s)) ?? [])
: []
const allow_user_resources =
@@ -81,12 +81,33 @@ export function computeS3FileInputPolicy(s3Config: any, app: App) {
}
}
export function computeS3ImageViewerPolicy(config: any, app: App) {
if (
config.sourceKind.value === 's3 (workspace storage)' ||
config.source.value.startsWith('s3://')
const partialS3ObjectSchema = z.object({
s3: z.string(),
storage: z.string().optional(),
presigned: z.string().optional()
})
export function isPartialS3Object(input: unknown): input is z.infer<typeof partialS3ObjectSchema> {
return partialS3ObjectSchema.safeParse(input).success
}
export function computeS3ImageViewerPolicy(config: RichConfigurations) {
if (config.source.type === 'uploadS3' && isPartialS3Object(config.source.value)) {
return {
s3_path: config.source.value.s3,
storage: config.source.value.storage
}
} else if (
config.source.type === 'static' &&
typeof config.source.value === 'string' &&
((config.sourceKind.type === 'static' &&
config.sourceKind.value === 's3 (workspace storage)') ||
config.source.value.startsWith('s3://'))
) {
return { s3_path: config.source.value.replace('s3://', ''), resource: 'default' }
return {
s3_path: config.source.value.replace('s3://', ''),
storage: undefined
}
} else {
return undefined
}

View File

@@ -484,6 +484,12 @@ export const selectOptions = {
animationTimingFunctionOptions: ['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out'],
prose: ['sm', 'Default', 'lg', 'xl', '2xl'],
imageSourceKind: [
'url',
'png encoded as base64',
'jpeg encoded as base64',
'svg encoded as base64'
],
imageSourceKindWithS3: [
'url',
's3 (workspace storage)',
'png encoded as base64',
@@ -2903,7 +2909,11 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
type: 'static',
fieldType: 'array',
subFieldType: 'object',
value: [{ header: 'First', foo: 1 }, { header: 'Second', foo: 2 }, { header: 'Third', foo: 3 }] as object[]
value: [
{ header: 'First', foo: 1 },
{ header: 'Second', foo: 2 },
{ header: 'Third', foo: 3 }
] as object[]
},
numberOfSubgrids: 1
}
@@ -3089,8 +3099,8 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
sourceKind: {
fieldType: 'select',
type: 'static',
selectOptions: selectOptions.imageSourceKind,
value: 'url' as (typeof selectOptions.imageSourceKind)[number]
selectOptions: selectOptions.imageSourceKindWithS3,
value: 'url' as (typeof selectOptions.imageSourceKindWithS3)[number]
},
imageFit: {
fieldType: 'select',

View File

@@ -64,15 +64,16 @@
let s3FilePicker: S3FilePicker | undefined
let s3PickerSelection: { s3: string; storage?: string } | undefined = undefined
let s3FolderPrefix: string = ''
let s3FileUploadRawMode = componentInput?.type == 'uploadS3' && !!componentInput.value?.s3
function updateSelectedS3File() {
if (s3PickerSelection) {
// @ts-ignore
componentInput = {
...componentInput,
type: 'static',
value: `s3://${s3PickerSelection.s3}`
if (componentInput.type === 'uploadS3') {
componentInput.value = {
...s3PickerSelection
}
}
s3FileUploadRawMode = true
}
}
@@ -162,6 +163,11 @@
(componentInput['expr'] == '' || componentInput['expr'] == undefined)
) {
componentInput['expr'] = JSON.stringify(componentInput['value'])
} else if (fileUploadS3 && fieldType === 'text' && e.detail != 'uploadS3') {
componentInput['value'] = ''
} else if (e.detail == 'uploadS3') {
s3FileUploadRawMode = false
componentInput['value'] = { s3: '' }
}
if (shouldFormatExpression) {
@@ -238,34 +244,52 @@
{:else if componentInput?.type === 'upload'}
<UploadInputEditor bind:componentInput {fileUpload} />
{:else if componentInput?.type === 'uploadS3'}
<div class="w-12/12 pb-2 flex flex-row mb-1 gap-1">
<input
type="text"
placeholder="S3 Folder prefix"
bind:value={s3FolderPrefix}
aria-label="S3 Folder prefix"
<div class="flex flex-col w-full gap-1">
<Toggle
class="flex justify-end"
bind:checked={s3FileUploadRawMode}
size="xs"
options={{ left: 'Raw S3 object input' }}
/>
{#if s3FileUploadRawMode}
{#await import('$lib/components/JsonEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
code={JSON.stringify(componentInput.value ?? { s3: '' }, null, 2)}
bind:value={componentInput.value}
/>
{/await}
{:else}
<input
type="text"
placeholder="S3 Folder prefix"
bind:value={s3FolderPrefix}
aria-label="S3 Folder prefix"
/>
<UploadInputEditor
bind:componentInput
fileUpload={fileUploadS3}
s3={true}
{workspace}
prefix={s3FolderPrefix}
bind:s3FileUploadRawMode
/>
{/if}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3PickerSelection = undefined
s3FilePicker?.open?.()
}}
startIcon={{ icon: Pipette }}
>
Choose an existing file
</Button>
</div>
<UploadInputEditor
bind:componentInput
fileUpload={fileUploadS3}
s3={true}
{workspace}
prefix={s3FolderPrefix}
/>
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3PickerSelection = undefined
s3FilePicker?.open?.()
}}
startIcon={{ icon: Pipette }}
>
Choose an existing file
</Button>
<S3FilePicker
bind:this={s3FilePicker}
folderOnly={false}

View File

@@ -10,7 +10,7 @@
import Toggle from '$lib/components/Toggle.svelte'
import autosize from '$lib/autosize'
import Button from '$lib/components/common/button/Button.svelte'
import { Settings } from 'lucide-svelte'
import { Loader2, Pipette, Settings } from 'lucide-svelte'
import AgGridWizard from '$lib/components/wizards/AgGridWizard.svelte'
import TableColumnWizard from '$lib/components/wizards/TableColumnWizard.svelte'
import PlotlyWizard from '$lib/components/wizards/PlotlyWizard.svelte'
@@ -23,6 +23,8 @@
import EditableSchemaDrawer from '$lib/components/schema/EditableSchemaDrawer.svelte'
import AppPicker from '$lib/components/wizards/AppPicker.svelte'
import JsonEditor from '$lib/components/JsonEditor.svelte'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import FileUpload from '$lib/components/common/fileUpload/FileUpload.svelte'
export let componentInput: StaticInput<any> | undefined
export let fieldType: InputType | undefined = undefined
@@ -34,6 +36,9 @@
const { onchange } = getContext<AppViewerContext>('AppViewerContext')
let s3FileUploadRawMode = false
let s3FilePicker: S3FilePicker | undefined = undefined
$: componentInput && onchange?.()
</script>
@@ -42,7 +47,8 @@
{#if fieldType === 'number' || fieldType === 'integer'}
<input on:keydown|stopPropagation type="number" bind:value={componentInput.value} />
{:else if fieldType === 'textarea'}
<textarea use:autosize on:keydown|stopPropagation bind:value={componentInput.value}></textarea>
<textarea use:autosize on:keydown|stopPropagation bind:value={componentInput.value}
></textarea>
{:else if fieldType === 'date'}
<input on:keydown|stopPropagation type="date" bind:value={componentInput.value} />
{:else if fieldType === 'time'}
@@ -135,7 +141,74 @@
{:else if fieldType === 'color'}
<ColorInput bind:value={componentInput.value} />
{:else if fieldType === 'object' || fieldType == 'labeledselect'}
{#if format?.startsWith('resource-') && (componentInput.value == undefined || typeof componentInput.value == 'string')}
{#if format && format.split('-').length > 1 && format
.replace('resource-', '')
.replace('_', '')
.toLowerCase() == 's3object'}
<div class="flex flex-col w-full gap-1">
<Toggle
class="flex justify-end"
bind:checked={s3FileUploadRawMode}
size="xs"
options={{ left: 'Raw S3 object input' }}
/>
{#if s3FileUploadRawMode}
{#await import('$lib/components/JsonEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
code={JSON.stringify(componentInput.value ?? { s3: '' }, null, 2)}
bind:value={componentInput.value}
/>
{/await}
{:else}
<FileUpload
allowMultiple={false}
randomFileKey={true}
on:addition={(evt) => {
if (componentInput) {
componentInput.value = {
s3: evt.detail?.path ?? '',
filename: evt.detail?.filename ?? ''
}
s3FileUploadRawMode = true
}
}}
on:deletion={(evt) => {
if (componentInput) {
componentInput.value = {
s3: ''
}
}
}}
/>
{/if}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.()
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
</div>
<S3FilePicker
bind:this={s3FilePicker}
readOnlyMode={false}
on:close={(e) => {
if (e.detail) {
if (componentInput) {
componentInput.value = e.detail
}
s3FileUploadRawMode = true
}
}}
/>
{:else if format?.startsWith('resource-') && (componentInput.value == undefined || typeof componentInput.value == 'string')}
<ResourcePicker
initialValue={componentInput.value?.split('$res:')?.[1] || ''}
on:change={(e) => {

View File

@@ -15,6 +15,7 @@
export let s3: boolean | undefined = false
export let prefix: string | undefined = undefined
export let workspace: string | undefined = undefined
export let s3FileUploadRawMode: boolean = false
let fileUploads: Writable<FileUploadData[]> = writable([])
@@ -38,11 +39,13 @@
return `${cleanPrefix}${file.name}`
}}
on:addition={({ detail }) => {
// @ts-ignore
componentInput = {
...componentInput,
type: 'static',
value: `s3://${detail.path}`
type: 'uploadS3',
value: { s3: detail.path }
}
s3FileUploadRawMode = true
}}
/>
{:else}

View File

@@ -78,7 +78,7 @@ export type UploadInput = {
export type UploadS3Input = {
type: 'uploadS3'
value: string
value: any
}
export type FileUploadData = {

View File

@@ -15,9 +15,9 @@ focus-within:border-blue-500 hover:bg-blue-50 dark:hover:bg-frost-900 focus-with
duration-200 rounded-lg p-1 gap-2"
href={`${base}/api/w/${workspaceId ?? $workspaceStore}${
appPath ? `/apps_u/download_s3_file/${appPath}` : '/job_helpers/download_s3_file'
}?file_key=${encodeURIComponent(s3object?.s3 ?? '')}${
}?${appPath ? 's3' : 'file_key'}=${encodeURIComponent(s3object?.s3 ?? '')}${
s3object?.storage ? `&storage=${s3object.storage}` : ''
}`}
}${appPath && s3object?.presigned ? `&${s3object.presigned}` : ''}`}
download={s3object?.s3.split('/').pop() ?? 'unnamed_download.file'}
>
<Download />

View File

@@ -19,8 +19,8 @@
export let containerText: string = folderOnly
? 'Drag and drop a folder here or click to browse'
: allowMultiple
? 'Drag and drop files here or click to browse'
: 'Drag and drop a file here or click to browse'
? 'Drag and drop files here or click to browse'
: 'Drag and drop a file here or click to browse'
export let customResourcePath: string | undefined = undefined
export let customResourceType: 's3' | 'azure_blob' | undefined = undefined // when customResourcePath is provided, this should be provided as well. Will default to S3 if not
export let customClass: string = ''
@@ -70,7 +70,10 @@
| undefined)
| undefined = undefined
const dispatch = createEventDispatcher()
const dispatch = createEventDispatcher<{
addition: { path?: string; filename?: string }
deletion: { path: string }
}>()
type FileUploadData = {
name: string
@@ -146,9 +149,9 @@
} else {
path =
typeof pathTransformer == 'function'
? (await pathTransformer?.({
? ((await pathTransformer?.({
file: fileToUpload
})) ?? fileToUploadKey
})) ?? fileToUploadKey)
: fileToUploadKey
}
const uploadData: FileUploadData = {
@@ -269,10 +272,10 @@
appPath
? `/api/w/${
workspace ?? $workspaceStore
}/apps_u/upload_s3_file/${appPath}?${params.toString()}`
}/apps_u/upload_s3_file/${appPath}?${params.toString()}`
: `/api/w/${
workspace ?? $workspaceStore
}/job_helpers/upload_s3_file?${params.toString()}`,
}/job_helpers/upload_s3_file?${params.toString()}`,
true
)
xhr?.setRequestHeader('Content-Type', 'application/octet-stream')
@@ -469,10 +472,10 @@
color={fileUpload.errorMessage
? '#ef4444'
: fileUpload.cancelled
? '#eab308'
: fileUpload.progress === 100
? '#22c55e'
: '#3b82f6'}
? '#eab308'
: fileUpload.progress === 100
? '#22c55e'
: '#3b82f6'}
ended={fileUpload.cancelled || fileUpload.errorMessage !== undefined}
>
{#if fileUpload.errorMessage}

View File

@@ -572,7 +572,7 @@
{/if}
{/if}
<!-- only render options dropdown if options or searchText is not empty (needed to avoid briefly flashing empty dropdown) -->
{#if (searchText && noMatchingOptionsMsg) || options?.length > 0}
{#if allowUserOptions || (searchText && noMatchingOptionsMsg) || options?.length > 0}
<div class="options bg-surface shadow-md rounded-component">
<VirtualList
width="100%"

View File

@@ -15,6 +15,7 @@
export let placeholder: string | undefined = undefined
export let target: string | HTMLElement | undefined = undefined
export let topPlacement = false
export let allowUserOptions: boolean | 'append' | undefined = undefined
const [floatingRef, floatingContent] = createFloatingActions({
strategy: 'absolute',
placement: topPlacement ? 'top-start' : 'bottom-start',
@@ -27,14 +28,13 @@
function moveOptionsToPortal() {
// Find ul element with class 'options' within the outerDiv
const ul = outerDiv?.querySelector('.options')
if (ul) {
// Move the ul element to the portal
portalRef?.appendChild(ul)
}
}
$: if (portalRef && outerDiv && items?.length > 0) {
$: if (portalRef && outerDiv && (allowUserOptions || items?.length > 0)) {
tick().then(() => {
moveOptionsToPortal()
})
@@ -53,6 +53,7 @@
{#if !value || Array.isArray(value)}
<div class="border rounded-md border-gray-300 shadow-sm dark:border-gray-600 !w-full">
<MultiSelect
{allowUserOptions}
outerDivClass={`!text-xs`}
ulSelectedClass="overflow-auto"
bind:outerDiv

View File

@@ -362,7 +362,7 @@
class="flex justify-end"
bind:checked={s3FileUploadRawMode}
size="xs"
options={{ left: 'Existing file' }}
options={{ left: 'Raw S3 object input' }}
disabled={!can_write}
/>
{/if}
@@ -385,20 +385,6 @@
code={JSON.stringify(static_asset_config ?? { s3: '' }, null, 2)}
/>
{/if}
{#if can_write}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(!is_static_website ? static_asset_config : undefined)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
{/if}
{:else}
{#key is_static_website}
<FileUpload
@@ -420,6 +406,20 @@
/>
{/key}
{/if}
{#if can_write}
<Button
variant="border"
color="light"
size="xs"
btnClasses="mt-1"
on:click={() => {
s3FilePicker?.open?.(!is_static_website ? static_asset_config : undefined)
}}
startIcon={{ icon: Pipette }}
>
Choose an object from the catalog
</Button>
{/if}
</div>
</div>
{:else}
@@ -612,10 +612,10 @@
href={itemKind === 'flow'
? `/flows/add?${SECRET_KEY_PATH}=${encodeURIComponent(variable_path)}&hub=${
HubFlow.SIGNATURE_TEMPLATE
}`
}`
: `/scripts/add?${SECRET_KEY_PATH}=${encodeURIComponent(
variable_path
)}&hub=hub%2F${HUB_SCRIPT_ID}`}
)}&hub=hub%2F${HUB_SCRIPT_ID}`}
target="_blank">Create from template</Button
>
</div>

View File

@@ -2,7 +2,6 @@
import { WorkspaceService, type AIConfig, type AIProvider } from '$lib/gen'
import { setCopilotInfo, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import MultiSelect from 'svelte-multiselect'
import { AI_DEFAULT_MODELS } from '../copilot/lib'
import TestAiKey from '../copilot/TestAIKey.svelte'
import Description from '../Description.svelte'
@@ -11,6 +10,7 @@
import Toggle from '../Toggle.svelte'
import ArgEnum from '../ArgEnum.svelte'
import Button from '../common/button/Button.svelte'
import MultiSelectWrapper from '../multiselect/MultiSelectWrapper.svelte'
const aiProviderLabels: [AIProvider, string][] = [
['openai', 'OpenAI'],
@@ -122,6 +122,22 @@
aiProviders = Object.fromEntries(
Object.entries(aiProviders).filter(([key]) => key !== provider)
)
if (defaultModel) {
const currentDefaultModel = Object.values(aiProviders).find(
(p) => defaultModel && p.models.includes(defaultModel)
)
if (!currentDefaultModel) {
defaultModel = undefined
}
}
if (codeCompletionModel) {
const currentCodeCompletionModel = Object.values(aiProviders).find(
(p) => codeCompletionModel && p.models.includes(codeCompletionModel)
)
if (!currentCodeCompletionModel) {
codeCompletionModel = undefined
}
}
}
}}
/>
@@ -139,8 +155,8 @@
bind:value={aiProviders[provider].resource_path}
on:change={() => {
if (
aiProviders[provider].resource_path &&
aiProviders[provider].models.length === 0 &&
aiProviders[provider]?.resource_path &&
aiProviders[provider]?.models.length === 0 &&
AI_DEFAULT_MODELS[provider].length > 0
) {
aiProviders[provider].models = AI_DEFAULT_MODELS[provider].slice(0, 1)
@@ -158,11 +174,11 @@
<Label label="Enabled models">
<!-- this can be removed once the parent component moves to runes -->
<!-- svelte-ignore binding_property_non_reactive -->
<MultiSelect
options={AI_DEFAULT_MODELS[provider]}
ulOptionsClass={'!bg-surface-secondary'}
<MultiSelectWrapper
items={AI_DEFAULT_MODELS[provider]}
bind:value={aiProviders[provider].models}
placeholder="Select models"
allowUserOptions="append"
bind:selected={aiProviders[provider].models}
/>
</Label>
</div>
@@ -177,16 +193,18 @@
<p class="font-semibold">Settings</p>
<div class="flex flex-col gap-4">
<Label label="Default chat model">
<ArgEnum
enum_={availableAIModels}
bind:value={defaultModel}
disabled={false}
autofocus={false}
defaultValue={undefined}
valid={true}
create={false}
required={false}
/>
{#key Object.keys(aiProviders).length}
<ArgEnum
enum_={availableAIModels}
bind:value={defaultModel}
disabled={false}
autofocus={false}
defaultValue={undefined}
valid={true}
create={false}
required={false}
/>
{/key}
</Label>
<div class="flex flex-col gap-2">

View File

@@ -0,0 +1,252 @@
<script lang="ts">
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { emptyString, sendUserToast } from '$lib/utils'
import { Plus, X } from 'lucide-svelte'
import Alert from '../common/alert/Alert.svelte'
import Button from '../common/button/Button.svelte'
import Tab from '../common/tabs/Tab.svelte'
import Tabs from '../common/tabs/Tabs.svelte'
import Description from '../Description.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import Toggle from '../Toggle.svelte'
import Tooltip from '../Tooltip.svelte'
import { convertFrontendToBackendSetting, type S3ResourceSettings } from '$lib/workspace_settings'
import { WorkspaceService } from '$lib/gen'
import S3FilePicker from '../S3FilePicker.svelte'
import Portal from '../Portal.svelte'
import { fade } from 'svelte/transition'
let { s3ResourceSettings = $bindable() }: { s3ResourceSettings: S3ResourceSettings } = $props()
let s3FileViewer: S3FilePicker | undefined = $state()
async function editWindmillLFSSettings(): Promise<void> {
const large_file_storage = convertFrontendToBackendSetting(s3ResourceSettings)
await WorkspaceService.editLargeFileStorageConfig({
workspace: $workspaceStore!,
requestBody: {
large_file_storage: large_file_storage
}
})
console.log('Large file storage settings changed', large_file_storage)
sendUserToast(`Large file storage settings changed`)
}
</script>
<Portal name="workspace-settings">
<S3FilePicker bind:this={s3FileViewer} readOnlyMode={false} fromWorkspaceSettings={true} />
</Portal>
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-primary text-lg font-semibold">Workspace Object Storage (S3/Azure Blob)</div>
<Description
link="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#workspace-object-storage"
>
Connect your Windmill workspace to your S3 bucket or your Azure Blob storage to enable users
to read and write from S3 without having to have access to the credentials.
</Description>
</div>
</div>
{#if !$enterpriseLicense}
<Alert type="info" title="S3 storage is limited to 20 files in Windmill CE">
Windmill S3 bucket browser will not work for buckets containing more than 20 files and uploads
are limited to files {'<'} 50MB. Consider upgrading to Windmill EE to use this feature with large
buckets.
</Alert>
{:else}
<Alert type="info" title="Logs storage is set at the instance level">
This setting is only for storage of large files allowing to upload files directly to object
storage using S3Object and use the wmill sdk to read and write large files backed by an object
storage. Large-scale log management and distributed dependency caching is under <a
href="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#instance-object-storage"
class="text-blue-500">Instance object storage</a
>, set by the superadmins in the instance settings UI.
</Alert>
{/if}
{#if s3ResourceSettings}
<div class="mt-5">
<div class="w-full">
<!-- this can be removed once parent moves to runes -->
<!-- svelte-ignore binding_property_non_reactive -->
<Tabs bind:selected={s3ResourceSettings.resourceType}>
<Tab exact size="xs" value="s3">S3</Tab>
<Tab size="xs" value="azure_blob">Azure Blob</Tab>
<Tab exact size="xs" value="s3_aws_oidc">AWS OIDC</Tab>
<Tab size="xs" value="azure_workload_identity">Azure Workload Identity</Tab>
</Tabs>
</div>
<div class="w-full flex gap-1 mt-4">
<!-- this can be removed once parent moves to runes -->
<!-- svelte-ignore binding_property_non_reactive -->
<ResourcePicker
resourceType={s3ResourceSettings.resourceType}
bind:value={s3ResourceSettings.resourcePath}
/>
<Button
size="sm"
variant="contained"
color="dark"
disabled={emptyString(s3ResourceSettings.resourcePath)}
on:click={async () => {
if ($workspaceStore) {
s3FileViewer?.open?.(undefined)
}
}}>Browse content (save first)</Button
>
</div>
</div>
{#if s3ResourceSettings.resourceType == 's3'}
<div class="flex flex-col mt-5 mb-1 gap-1">
<!-- this can be removed once parent moves to runes -->
<!-- svelte-ignore binding_property_non_reactive -->
<Toggle
disabled={emptyString(s3ResourceSettings.resourcePath)}
bind:checked={s3ResourceSettings.publicResource}
options={{
right: 'S3 resource details and content can be accessed by all users of this workspace',
rightTooltip:
'If set, all users of this workspace will have access the to entire content of the S3 bucket, as well as the resource details and the "open preview" button. This effectively by-pass the permissions set on the resource and makes it public to everyone.'
}}
/>
{#if s3ResourceSettings.publicResource === true}
<div class="pt-2"></div>
<Alert type="warning" title="S3 bucket content and resource details are shared">
S3 resource public access is ON, which means that the entire content of the S3 bucket will
be accessible to all the users of this workspace regardless of whether they have access
the resource or not. Similarly, certain Windmill SDK endpoints can be used in scripts to
access the resource details, including public and private keys.
</Alert>
{/if}
</div>
{:else}
<div class="flex flex-col mt-5 mb-1 gap-1">
<!-- this can be removed once parent moves to runes -->
<!-- svelte-ignore binding_property_non_reactive -->
<Toggle
disabled={emptyString(s3ResourceSettings.resourcePath)}
bind:checked={s3ResourceSettings.publicResource}
options={{
right: 'object storage content can be accessed by all users of this workspace',
rightTooltip:
'If set, all users of this workspace will have access the to entire content of the object storage.'
}}
/>
{#if s3ResourceSettings.publicResource === true}
<div class="pt-2"></div>
<Alert type="warning" title="object content">
object public access is ON, which means that the entire content of the object store will
be accessible to all the users of this workspace regardless of whether they have access
the resource or not.
</Alert>
{/if}
</div>
{/if}
<div class="mt-6">
<div class="flex mt-2 flex-col gap-y-4 max-w-3xl">
{#each s3ResourceSettings.secondaryStorage ?? [] as _, idx}
<div class="flex gap-1 items-center">
<input
class="max-w-[200px]"
type="text"
bind:value={
() => s3ResourceSettings.secondaryStorage?.[idx]?.[0] || '',
(v) => {
if (s3ResourceSettings.secondaryStorage?.[idx]) {
s3ResourceSettings.secondaryStorage[idx][0] = v
}
}
}
placeholder="Storage name"
/>
<select
class="max-w-[125px]"
bind:value={
() => s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourceType || 's3',
(v) => {
if (s3ResourceSettings.secondaryStorage?.[idx]) {
s3ResourceSettings.secondaryStorage[idx][1].resourceType = v
}
}
}
>
<option value="s3">S3</option>
<option value="azure_blob">Azure Blob</option>
<option value="s3_aws_oidc">AWS OIDC</option>
<option value="azure_workload_identity">Azure Workload Identity</option>
</select>
<!-- this can be removed once parent moves to runes -->
<!-- svelte-ignore binding_property_non_reactive -->
<ResourcePicker
resourceType={s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourceType || 's3'}
bind:value={
() => s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourcePath || '',
(v) => {
if (s3ResourceSettings.secondaryStorage?.[idx]) {
s3ResourceSettings.secondaryStorage[idx][1].resourcePath = v
}
}
}
/>
<Button
size="sm"
variant="contained"
color="dark"
disabled={emptyString(s3ResourceSettings.secondaryStorage?.[idx]?.[1].resourcePath)}
on:click={async () => {
if ($workspaceStore) {
s3FileViewer?.open?.({
s3: '',
storage: s3ResourceSettings.secondaryStorage?.[idx]?.[0] || ''
})
}
}}>Browse content (save first)</Button
>
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
aria-label="Clear"
onclick={() => {
if (s3ResourceSettings.secondaryStorage) {
s3ResourceSettings.secondaryStorage.splice(idx, 1)
s3ResourceSettings.secondaryStorage = [...s3ResourceSettings.secondaryStorage]
}
}}
>
<X size={14} />
</button>
</div>
{/each}
<div class="flex gap-1">
<Button
size="xs"
variant="border"
on:click={() => {
if (s3ResourceSettings.secondaryStorage === undefined) {
s3ResourceSettings.secondaryStorage = []
}
s3ResourceSettings.secondaryStorage.push([
`storage_${s3ResourceSettings.secondaryStorage.length + 1}`,
{ resourcePath: '', resourceType: 's3', publicResource: false }
])
s3ResourceSettings.secondaryStorage = s3ResourceSettings.secondaryStorage
}}><Plus size={14} />Add secondary storage</Button
>
<Tooltip>
Secondary storage is a feature that allows you to read and write from storage that isn't
your main storage by specifying it in the s3 object as "secondary_storage" with the name
of it
</Tooltip>
</div>
</div>
</div>
<div class="flex mt-5 mb-5 gap-1">
<Button
on:click={() => {
editWindmillLFSSettings()
console.log('Saving S3 settings', s3ResourceSettings)
}}>Save storage settings</Button
>
</div>
{/if}

View File

@@ -10,7 +10,6 @@
import PageHeader from '$lib/components/PageHeader.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import WorkspaceUserSettings from '$lib/components/settings/WorkspaceUserSettings.svelte'
@@ -46,7 +45,6 @@
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Portal from '$lib/components/Portal.svelte'
import { fade } from 'svelte/transition'
import ChangeWorkspaceName from '$lib/components/settings/ChangeWorkspaceName.svelte'
@@ -54,14 +52,14 @@
import ChangeWorkspaceColor from '$lib/components/settings/ChangeWorkspaceColor.svelte'
import {
convertBackendSettingsToFrontendSettings,
convertFrontendToBackendSetting,
type S3ResourceSettings
} from '$lib/workspace_settings'
import { base } from '$lib/base'
import { hubPaths } from '$lib/hub'
import Description from '$lib/components/Description.svelte'
import ConnectionSection from '$lib/components/ConnectionSection.svelte'
import AiSettings from '$lib/components/workspaceSettings/AISettings.svelte'
import AISettings from '$lib/components/workspaceSettings/AISettings.svelte'
import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte'
type GitSyncTypeMap = {
scripts: boolean
@@ -89,8 +87,6 @@
| 'user'
| 'group'
let s3FileViewer: S3FilePicker
let slackInitialPath: string
let slackScriptPath: string
let teamsInitialPath: string
@@ -212,18 +208,6 @@
}
}
async function editWindmillLFSSettings(): Promise<void> {
const large_file_storage = convertFrontendToBackendSetting(s3ResourceSettings)
await WorkspaceService.editLargeFileStorageConfig({
workspace: $workspaceStore!,
requestBody: {
large_file_storage: large_file_storage
}
})
console.log('Large file storage settings changed', large_file_storage)
sendUserToast(`Large file storage settings changed`)
}
async function editWindmillGitSyncSettings(): Promise<void> {
let alreadySeenResource: string[] = []
let repositories = gitSyncSettings.repositories.map((elmt) => {
@@ -629,10 +613,6 @@
$: updateFromSearchTab($page.url.searchParams.get('tab'))
</script>
<Portal name="workspace-settings">
<S3FilePicker bind:this={s3FileViewer} readOnlyMode={false} fromWorkspaceSettings={true} />
</Portal>
<CenteredPage>
{#if $userStore?.is_admin || $superadmin}
<PageHeader title="Workspace settings: {$workspaceStore}"
@@ -1009,196 +989,14 @@
</div>
</div>
{:else if tab == 'ai'}
<AiSettings
{aiProviders}
{codeCompletionModel}
{defaultModel}
{usingOpenaiClientCredentialsOauth}
<AISettings
bind:aiProviders
bind:codeCompletionModel
bind:defaultModel
bind:usingOpenaiClientCredentialsOauth
/>
{:else if tab == 'windmill_lfs'}
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-primary text-lg font-semibold"
>Workspace Object Storage (S3/Azure Blob)</div
>
<Description
link="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#workspace-object-storage"
>
Connect your Windmill workspace to your S3 bucket or your Azure Blob storage to enable
users to read and write from S3 without having to have access to the credentials.
</Description>
</div>
</div>
{#if !$enterpriseLicense}
<Alert type="info" title="S3 storage is limited to 20 files in Windmill CE">
Windmill S3 bucket browser will not work for buckets containing more than 20 files and
uploads are limited to files {'<'} 50MB. Consider upgrading to Windmill EE to use this feature
with large buckets.
</Alert>
{:else}
<Alert type="info" title="Logs storage is set at the instance level">
This setting is only for storage of large files allowing to upload files directly to
object storage using S3Object and use the wmill sdk to read and write large files backed
by an object storage. Large-scale log management and distributed dependency caching is
under <a
href="https://www.windmill.dev/docs/core_concepts/object_storage_in_windmill#instance-object-storage"
class="text-blue-500">Instance object storage</a
>, set by the superadmins in the instance settings UI.
</Alert>
{/if}
{#if s3ResourceSettings}
<div class="mt-5">
<div class="w-full">
<Tabs bind:selected={s3ResourceSettings.resourceType}>
<Tab exact size="xs" value="s3">S3</Tab>
<Tab size="xs" value="azure_blob">Azure Blob</Tab>
<Tab exact size="xs" value="s3_aws_oidc">AWS OIDC</Tab>
<Tab size="xs" value="azure_workload_identity">Azure Workload Identity</Tab>
</Tabs>
</div>
<div class="w-full flex gap-1 mt-4">
<ResourcePicker
resourceType={s3ResourceSettings.resourceType}
bind:value={s3ResourceSettings.resourcePath}
/>
<Button
size="sm"
variant="contained"
color="dark"
disabled={emptyString(s3ResourceSettings.resourcePath)}
on:click={async () => {
if ($workspaceStore) {
s3FileViewer?.open?.(undefined)
}
}}>Browse content (save first)</Button
>
</div>
</div>
{#if s3ResourceSettings.resourceType == 's3'}
<div class="flex flex-col mt-5 mb-1 gap-1">
<Toggle
disabled={emptyString(s3ResourceSettings.resourcePath)}
bind:checked={s3ResourceSettings.publicResource}
options={{
right:
'S3 resource details and content can be accessed by all users of this workspace',
rightTooltip:
'If set, all users of this workspace will have access the to entire content of the S3 bucket, as well as the resource details and the "open preview" button. This effectively by-pass the permissions set on the resource and makes it public to everyone.'
}}
/>
{#if s3ResourceSettings.publicResource === true}
<div class="pt-2"></div>
<Alert type="warning" title="S3 bucket content and resource details are shared">
S3 resource public access is ON, which means that the entire content of the S3
bucket will be accessible to all the users of this workspace regardless of whether
they have access the resource or not. Similarly, certain Windmill SDK endpoints can
be used in scripts to access the resource details, including public and private
keys.
</Alert>
{/if}
</div>
{:else}
<div class="flex flex-col mt-5 mb-1 gap-1">
<Toggle
disabled={emptyString(s3ResourceSettings.resourcePath)}
bind:checked={s3ResourceSettings.publicResource}
options={{
right: 'object storage content can be accessed by all users of this workspace',
rightTooltip:
'If set, all users of this workspace will have access the to entire content of the object storage.'
}}
/>
{#if s3ResourceSettings.publicResource === true}
<div class="pt-2"></div>
<Alert type="warning" title="object content">
object public access is ON, which means that the entire content of the object store
will be accessible to all the users of this workspace regardless of whether they
have access the resource or not.
</Alert>
{/if}
</div>
{/if}
<div class="mt-6">
<div class="flex mt-2 flex-col gap-y-4 max-w-3xl">
{#each s3ResourceSettings.secondaryStorage ?? [] as secondaryStorage, idx}
<div class="flex gap-1 items-center">
<input
class="max-w-[200px]"
type="text"
bind:value={secondaryStorage[0]}
placeholder="Storage name"
/>
<select class="max-w-[125px]" bind:value={secondaryStorage[1].resourceType}>
<option value="s3">S3</option>
<option value="azure_blob">Azure Blob</option>
<option value="s3_aws_oidc">AWS OIDC</option>
<option value="azure_workload_identity">Azure Workload Identity</option>
</select>
<ResourcePicker
resourceType={secondaryStorage[1].resourceType}
bind:value={secondaryStorage[1].resourcePath}
/>
<Button
size="sm"
variant="contained"
color="dark"
disabled={emptyString(secondaryStorage[1].resourcePath)}
on:click={async () => {
if ($workspaceStore) {
s3FileViewer?.open?.({ s3: '', storage: secondaryStorage[0] })
}
}}>Browse content (save first)</Button
>
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-1 bg-surface-secondary duration-200 hover:bg-surface-hover ml-2"
aria-label="Clear"
on:click={() => {
if (s3ResourceSettings.secondaryStorage) {
s3ResourceSettings.secondaryStorage.splice(idx, 1)
s3ResourceSettings.secondaryStorage = [...s3ResourceSettings.secondaryStorage]
}
}}
>
<X size={14} />
</button>
</div>
{/each}
<div class="flex gap-1">
<Button
size="xs"
variant="border"
on:click={() => {
if (s3ResourceSettings.secondaryStorage === undefined) {
s3ResourceSettings.secondaryStorage = []
}
s3ResourceSettings.secondaryStorage.push([
`storage_${s3ResourceSettings.secondaryStorage.length + 1}`,
{ resourcePath: '', resourceType: 's3', publicResource: false }
])
s3ResourceSettings.secondaryStorage = s3ResourceSettings.secondaryStorage
}}><Plus size={14} />Add secondary storage</Button
>
<Tooltip>
Secondary storage is a feature that allows you to read and write from storage that
isn't your main storage by specifying it in the s3 object as "secondary_storage"
with the name of it
</Tooltip>
</div>
</div>
</div>
<div class="flex mt-5 mb-5 gap-1">
<Button
color="blue"
disabled={emptyString(s3ResourceSettings.resourcePath)}
on:click={() => {
editWindmillLFSSettings()
console.log('Saving S3 settings', s3ResourceSettings)
}}>Save storage settings</Button
>
</div>
{/if}
<StorageSettings bind:s3ResourceSettings />
{:else if tab == 'git_sync'}
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">

View File

@@ -586,6 +586,12 @@ class Windmill:
raise Exception("Could not write file to S3") from e
return S3Object(s3=response["file_key"])
def sign_s3_objects(self, s3_objects: list[S3Object]) -> list[S3Object]:
return self.post(f"/w/{self.workspace}/apps/sign_s3_objects", json={"s3_objects": s3_objects}).json()
def sign_s3_object(self, s3_object: S3Object) -> S3Object:
return self.post(f"/w/{self.workspace}/apps/sign_s3_objects", json={"s3_objects": [s3_object]}).json()[0]
def __boto3_connection_settings(self, s3_resource) -> Boto3ConnectionSettings:
endpoint_url_prefix = "https://" if s3_resource["useSSL"] else "http://"
return Boto3ConnectionSettings(
@@ -974,6 +980,24 @@ def write_s3_file(
return _client.write_s3_file(s3object, file_content, s3_resource_path if s3_resource_path != "" else None, content_type, content_disposition)
@init_global_client
def sign_s3_objects(s3_objects: list[S3Object]) -> list[S3Object]:
"""
Sign S3 objects to be used by anonymous users in public apps
Returns a list of signed s3 tokens
"""
return _client.sign_s3_objects(s3_objects)
@init_global_client
def sign_s3_object(s3_object: S3Object) -> S3Object:
"""
Sign S3 object to be used by anonymous users in public apps
Returns a signed s3 object
"""
return _client.sign_s3_object(s3_object)
@init_global_client
def whoami() -> dict:
"""

View File

@@ -1,6 +1,7 @@
class S3Object(dict):
s3: str
storage: str | None
presigned: str | None
def __getattr__(self, attr):
return self[attr]

View File

@@ -14,5 +14,5 @@ cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export type { S3Object, DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql } from "./client";' >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql } from "./client";' >> "${script_dirpath}/src/index.ts"

View File

@@ -39,4 +39,4 @@ cp "${script_dirpath}/s3Types.ts" "${script_dirpath}/src/"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export type { S3Object, DenoS3LightClientSettings } from "./s3Types";' >> "${script_dirpath}/src/index.ts"
echo "" >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql } from "./client";' >> "${script_dirpath}/src/index.ts"
echo 'export { type Base64, setClient, getVariable, setVariable, getResource, setResource, getResumeUrls, setState, setProgress, getProgress, getState, getIdToken, denoS3LightClientSettings, loadS3FileStream, loadS3File, writeS3File, signS3Objects, signS3Object, task, runScript, runScriptAsync, runFlow, runFlowAsync, waitJob, getRootJobId, setFlowUserState, getFlowUserState, usernameToEmail, requestInteractiveSlackApproval, Sql } from "./client";' >> "${script_dirpath}/src/index.ts"

View File

@@ -3,10 +3,11 @@ import {
VariableService,
JobService,
HelpersService,
AppService,
MetricsService,
OidcService,
UserService,
TeamsService
TeamsService,
} from "./index";
import { OpenAPI } from "./index";
// import type { DenoS3LightClientSettings } from "./index";
@@ -770,6 +771,33 @@ export async function writeS3File(
};
}
/**
* Sign S3 objects to be used by anonymous users in public apps
* @param s3objects s3 objects to sign
* @returns signed s3 objects
*/
export async function signS3Objects(
s3objects: S3Object[]
): Promise<S3Object[]> {
const signedKeys = await AppService.signS3Objects({
workspace: getWorkspace(),
requestBody: {
s3_objects: s3objects,
},
});
return signedKeys;
}
/**
* Sign S3 object to be used by anonymous users in public apps
* @param s3object s3 object to sign
* @returns signed s3 object
*/
export async function signS3Object(s3object: S3Object): Promise<S3Object> {
const [signedObject] = await signS3Objects([s3object]);
return signedObject;
}
/**
* Get URLs needed for resuming a flow after this step
* @param approver approver name

View File

@@ -1,6 +1,7 @@
export type S3Object = {
s3: string;
storage?: string;
presigned?: string;
};
export type DenoS3LightClientSettings = {