mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 16:03:26 +00:00
presigned
This commit is contained in:
@@ -6323,12 +6323,7 @@ paths:
|
||||
s3_objects:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
s3:
|
||||
type: string
|
||||
storage:
|
||||
type: string
|
||||
$ref: "#/components/schemas/S3Object"
|
||||
responses:
|
||||
"200":
|
||||
description: signed s3 tokens
|
||||
@@ -6337,7 +6332,7 @@ paths:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
$ref: "#/components/schemas/S3Object"
|
||||
|
||||
/w/{workspace}/apps_u/execute_component/{path}:
|
||||
post:
|
||||
@@ -16053,3 +16048,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
|
||||
@@ -70,10 +70,14 @@ use windmill_common::{
|
||||
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 {
|
||||
@@ -236,7 +240,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)]
|
||||
@@ -1568,18 +1573,6 @@ struct S3DeleteTokenClaims {
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SignedS3TokenClaims {
|
||||
file_key: String,
|
||||
storage: Option<String>,
|
||||
workspace: String,
|
||||
pub exp: usize,
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
const S3_TOKEN_PREFIX: &str = "__wm_s3_token_";
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
#[derive(Deserialize)]
|
||||
struct S3TokenRequestBody {
|
||||
@@ -1587,23 +1580,74 @@ struct S3TokenRequestBody {
|
||||
}
|
||||
#[cfg(feature = "parquet")]
|
||||
async fn sign_s3_objects(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(body): Json<S3TokenRequestBody>,
|
||||
) -> Result<Json<Vec<String>>> {
|
||||
let futures = body.s3_objects.iter().map(|s3_object| async {
|
||||
jwt::encode_with_internal_secret(SignedS3TokenClaims {
|
||||
file_key: s3_object.s3.clone(),
|
||||
storage: s3_object.storage.clone(),
|
||||
workspace: w_id.clone(),
|
||||
exp: (chrono::Utc::now() + chrono::Duration::hours(12)).timestamp() as usize,
|
||||
})
|
||||
.await
|
||||
.map(|token| format!("{}{}", S3_TOKEN_PREFIX, token))
|
||||
) -> 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 tokens = futures::future::try_join_all(futures).await?;
|
||||
let signed_s3_objects = futures::future::try_join_all(futures).await?;
|
||||
|
||||
Ok(Json(tokens))
|
||||
Ok(Json(signed_s3_objects))
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
async fn validate_s3_signature(s3_object: &S3Object, w_id: &str, db: &DB) -> Result<()> {
|
||||
use url::form_urlencoded;
|
||||
|
||||
let workspace_key = get_workspace_key(w_id, &db).await?;
|
||||
|
||||
let params: HashMap<_, _> =
|
||||
form_urlencoded::parse(s3_object.presigned.as_ref().unwrap().as_bytes())
|
||||
.into_owned()
|
||||
.collect();
|
||||
|
||||
let exp = params
|
||||
.get("exp")
|
||||
.map(|s| s.parse::<i64>().unwrap_or_default())
|
||||
.ok_or_else(|| Error::NotAuthorized("Missing exp".to_string()))?;
|
||||
|
||||
let signature = params
|
||||
.get("sig")
|
||||
.ok_or_else(|| Error::NotAuthorized("Missing signature".to_string()))?;
|
||||
|
||||
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 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 signature_bytes = hex::decode(signature)?;
|
||||
mac.verify_slice(&signature_bytes)
|
||||
.map_err(|err| Error::NotAuthorized(format!("Invalid signature: {}", err)))?;
|
||||
|
||||
if exp < chrono::Utc::now().timestamp() {
|
||||
return Err(Error::NotAuthorized("Signature expired".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "parquet"))]
|
||||
@@ -2014,7 +2058,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_query: &mut LoadImagePreviewQuery,
|
||||
file_query: &mut S3Object,
|
||||
w_id: &str,
|
||||
path: &str,
|
||||
policy: &Policy,
|
||||
@@ -2022,15 +2066,8 @@ 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)
|
||||
|
||||
if file_query.file_key.starts_with(S3_TOKEN_PREFIX) {
|
||||
let token = file_query.file_key.strip_prefix(S3_TOKEN_PREFIX).unwrap();
|
||||
let claims = jwt::decode_with_internal_secret::<SignedS3TokenClaims>(token).await?;
|
||||
if claims.workspace != w_id {
|
||||
return Err(Error::BadRequest("Invalid workspace in token".to_string()));
|
||||
}
|
||||
file_query.file_key = claims.file_key;
|
||||
file_query.storage = claims.storage;
|
||||
return Ok(());
|
||||
if file_query.presigned.is_some() {
|
||||
validate_s3_signature(file_query, w_id, &db).await?;
|
||||
}
|
||||
|
||||
let allowed = opt_authed.is_some()
|
||||
@@ -2039,7 +2076,7 @@ async fn check_if_allowed_to_access_s3_file_from_app(
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|key| key.s3_path == file_query.file_key)
|
||||
.any(|key| key.s3_path == file_query.s3 && key.storage == file_query.storage)
|
||||
|| {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS (
|
||||
@@ -2051,7 +2088,7 @@ async fn check_if_allowed_to_access_s3_file_from_app(
|
||||
AND script_path LIKE $3 || '/%'
|
||||
AND result @> ('{"s3":"' || $1 || '"}')::jsonb
|
||||
)"#,
|
||||
file_query.file_key,
|
||||
file_query.s3,
|
||||
w_id,
|
||||
path,
|
||||
)
|
||||
@@ -2069,29 +2106,18 @@ async fn check_if_allowed_to_access_s3_file_from_app(
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
#[derive(Deserialize)]
|
||||
pub struct FileQueryWithForceViewerAllowedS3Keys {
|
||||
pub struct S3ObjectWithForceViewerAllowedS3Keys {
|
||||
#[serde(flatten)]
|
||||
pub file_query: LoadImagePreviewQuery,
|
||||
pub file_query: S3Object,
|
||||
pub force_viewer_allowed_s3_keys: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
impl From<LoadImagePreviewQuery> for DownloadFileQuery {
|
||||
fn from(query: LoadImagePreviewQuery) -> Self {
|
||||
DownloadFileQuery {
|
||||
file_key: query.file_key,
|
||||
storage: query.storage,
|
||||
s3_resource_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
async fn download_s3_file_from_app(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(mut query): Query<FileQueryWithForceViewerAllowedS3Keys>,
|
||||
Query(mut query): Query<S3ObjectWithForceViewerAllowedS3Keys>,
|
||||
) -> Result<Response> {
|
||||
let path = path.to_path();
|
||||
|
||||
@@ -2123,7 +2149,11 @@ async fn download_s3_file_from_app(
|
||||
None,
|
||||
"",
|
||||
&w_id,
|
||||
query.file_query.into(),
|
||||
DownloadFileQuery {
|
||||
file_key: query.file_query.s3,
|
||||
s3_resource_path: None,
|
||||
storage: query.file_query.storage,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2140,7 +2170,7 @@ 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(mut query): Query<LoadImagePreviewQuery>,
|
||||
Query(mut query): Query<S3Object>,
|
||||
) -> Result<Response> {
|
||||
let path = path.to_path();
|
||||
|
||||
@@ -2157,7 +2187,14 @@ async fn load_s3_file_image_preview_from_app(
|
||||
)
|
||||
.await?;
|
||||
|
||||
load_image_preview_internal(on_behalf_authed, &db, "", &w_id, query).await
|
||||
load_image_preview_internal(
|
||||
on_behalf_authed,
|
||||
&db,
|
||||
"",
|
||||
&w_id,
|
||||
LoadImagePreviewQuery { file_key: query.s3, storage: query.storage },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -902,7 +902,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;
|
||||
|
||||
Generated
+4
-3
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -776,7 +776,7 @@
|
||||
appPath
|
||||
? 'apps_u/load_image_preview/' + appPath
|
||||
: 'job_helpers/load_image_preview'
|
||||
}?file_key=${encodeURIComponent(result.s3)}` +
|
||||
}?${appPath ? 's3' : 'file_key'}=${encodeURIComponent(result.s3)}` +
|
||||
(result.storage ? `&storage=${result.storage}` : '')}
|
||||
/>
|
||||
</div>
|
||||
@@ -788,7 +788,7 @@
|
||||
appPath
|
||||
? 'apps_u/load_image_preview/' + appPath
|
||||
: 'job_helpers/load_image_preview'
|
||||
}?file_key=${encodeURIComponent(result.s3)}` +
|
||||
}?${appPath ? 's3' : 'file_key'}=${encodeURIComponent(result.s3)}` +
|
||||
(result.storage ? `&storage=${result.storage}` : '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,14 +46,17 @@
|
||||
|
||||
let imageUrl: string | undefined = undefined
|
||||
|
||||
async function getS3Image(source: string | undefined) {
|
||||
async function getS3Image(source: string | undefined, storage?: 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) {
|
||||
@@ -64,7 +67,11 @@
|
||||
}
|
||||
|
||||
async function loadImage() {
|
||||
if (
|
||||
if (isPartialS3Object(resolvedConfig.source)) {
|
||||
imageUrl = await getS3Image(resolvedConfig.source.s3, resolvedConfig.source.storage)
|
||||
} else if (resolvedConfig.source && typeof resolvedConfig.source !== 'string') {
|
||||
throw new Error('Invalid s3 object' + typeof resolvedConfig.source)
|
||||
} else if (
|
||||
resolvedConfig.sourceKind === 's3 (workspace storage)' ||
|
||||
resolvedConfig.source?.startsWith('s3://')
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,35 @@ 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://')
|
||||
) {
|
||||
return { s3_path: config.source.value.replace('s3://', ''), resource: 'default' }
|
||||
const partialS3ObjectSchema = z.object({
|
||||
s3: z.string(),
|
||||
storage: 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 === 'static') {
|
||||
if (isPartialS3Object(config.source.value)) {
|
||||
return {
|
||||
s3_path: config.source.value.s3,
|
||||
storage: config.source.value.storage
|
||||
}
|
||||
} else if (
|
||||
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://', ''),
|
||||
storage: undefined
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -64,15 +64,18 @@
|
||||
let s3FilePicker: S3FilePicker | undefined
|
||||
let s3PickerSelection: { s3: string; storage?: string } | undefined = undefined
|
||||
let s3FolderPrefix: string = ''
|
||||
let s3FileUploadRawMode = componentInput?.type == 'uploadS3' && !!componentInput.value?.s3
|
||||
|
||||
$: console.log('rawmode', s3FileUploadRawMode)
|
||||
|
||||
function updateSelectedS3File() {
|
||||
if (s3PickerSelection) {
|
||||
// @ts-ignore
|
||||
componentInput = {
|
||||
...componentInput,
|
||||
type: 'static',
|
||||
value: `s3://${s3PickerSelection.s3}`
|
||||
if (componentInput.type === 'uploadS3') {
|
||||
componentInput.value = {
|
||||
...s3PickerSelection
|
||||
}
|
||||
}
|
||||
s3FileUploadRawMode = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +121,8 @@
|
||||
onConnect: applyConnection
|
||||
}
|
||||
}
|
||||
|
||||
$: console.log('input', componentInput)
|
||||
</script>
|
||||
|
||||
{#if !(resourceOnly && (fieldType !== 'object' || !format?.startsWith('resource-')))}
|
||||
@@ -162,6 +167,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 +248,55 @@
|
||||
{: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
|
||||
on:focus={(e) => {
|
||||
dispatch('focus')
|
||||
}}
|
||||
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}
|
||||
|
||||
+2
-1
@@ -42,7 +42,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'}
|
||||
|
||||
+5
-2
@@ -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}
|
||||
|
||||
@@ -78,7 +78,7 @@ export type UploadInput = {
|
||||
|
||||
export type UploadS3Input = {
|
||||
type: 'uploadS3'
|
||||
value: string
|
||||
value: any
|
||||
}
|
||||
|
||||
export type FileUploadData = {
|
||||
|
||||
@@ -15,7 +15,7 @@ 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}` : ''
|
||||
}`}
|
||||
download={s3object?.s3.split('/').pop() ?? 'unnamed_download.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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user