mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +00:00
feat: s3 input available for public apps (#4685)
This commit is contained in:
@@ -1 +1 @@
|
||||
6534b0f31fb4a75dd706fca2ce91e37e77e4ad02
|
||||
8f45974252a7ce6fcf8f49482751ffa75b81bed7
|
||||
@@ -12186,6 +12186,10 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: object
|
||||
s3_inputs:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
execution_mode:
|
||||
type: string
|
||||
enum: [viewer, publisher, anonymous]
|
||||
|
||||
@@ -7,6 +7,12 @@ use std::collections::HashMap;
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
use crate::{job_helpers_ee::{
|
||||
get_random_file_name, get_s3_resource, get_workspace_s3_resource, upload_file_internal,
|
||||
UploadFileResponse,
|
||||
}, users::fetch_api_authed_from_permissioned_as};
|
||||
use crate::{
|
||||
db::{ApiAuthed, DB},
|
||||
resources::get_resource_value_interpolated_internal,
|
||||
@@ -23,7 +29,13 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
#[cfg(feature = "parquet")]
|
||||
use itertools::Itertools;
|
||||
use magic_crypt::MagicCryptTrait;
|
||||
#[cfg(feature = "parquet")]
|
||||
use object_store::{Attribute, Attributes};
|
||||
#[cfg(feature = "parquet")]
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, value::RawValue};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -32,6 +44,8 @@ use sqlx::{types::Uuid, FromRow};
|
||||
use std::str;
|
||||
use windmill_audit::audit_ee::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
#[cfg(feature = "parquet")]
|
||||
use windmill_common::s3_helpers::build_object_store_client;
|
||||
use windmill_common::{
|
||||
apps::ListAppQuery,
|
||||
db::UserDB,
|
||||
@@ -69,6 +83,7 @@ pub fn workspaced_service() -> Router {
|
||||
pub fn unauthed_service() -> Router {
|
||||
Router::new()
|
||||
.route("/execute_component/*path", post(execute_component))
|
||||
.route("/upload_s3_file/*path", post(upload_s3_file_from_app))
|
||||
.route("/public_app/:secret", get(get_public_app_by_secret))
|
||||
.route("/public_resource/*path", get(get_public_resource))
|
||||
}
|
||||
@@ -179,6 +194,14 @@ pub struct PolicyTriggerableInputs {
|
||||
allow_user_resources: AllowUserResources,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct S3Input {
|
||||
allowed_resources: Vec<String>,
|
||||
allow_user_resources: bool,
|
||||
allow_workspace_resource: bool,
|
||||
file_key_regex: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Policy {
|
||||
pub on_behalf_of: Option<String>,
|
||||
@@ -192,6 +215,7 @@ pub struct Policy {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub triggerables_v2: Option<HashMap<String, PolicyTriggerableInputs>>,
|
||||
pub execution_mode: ExecutionMode,
|
||||
pub s3_inputs: Option<Vec<S3Input>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -432,9 +456,7 @@ async fn get_latest_version(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
|
||||
) -> JsonResult<Option<AppHistory>> {
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let row = sqlx::query!(
|
||||
"SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg
|
||||
@@ -457,7 +479,6 @@ async fn get_latest_version(
|
||||
} else {
|
||||
return Ok(Json(None));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async fn update_app_history(
|
||||
@@ -1067,6 +1088,49 @@ fn digest(code: &str) -> String {
|
||||
format!("rawscript/{:x}", result)
|
||||
}
|
||||
|
||||
async fn get_on_behalf_details_from_policy_and_authed(
|
||||
policy: &Policy,
|
||||
opt_authed: &Option<ApiAuthed>,
|
||||
) -> Result<(String, String, String)> {
|
||||
let (username, permissioned_as, email) = match policy.execution_mode {
|
||||
ExecutionMode::Anonymous => {
|
||||
let username = opt_authed
|
||||
.as_ref()
|
||||
.map(|a| a.username.clone())
|
||||
.unwrap_or_else(|| "anonymous".to_string());
|
||||
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
|
||||
(username, permissioned_as, email)
|
||||
}
|
||||
ExecutionMode::Publisher => {
|
||||
let username = opt_authed
|
||||
.as_ref()
|
||||
.map(|a| a.username.clone())
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(
|
||||
"publisher execution mode requires authentication".to_string(),
|
||||
)
|
||||
})?;
|
||||
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
|
||||
(username, permissioned_as, email)
|
||||
}
|
||||
ExecutionMode::Viewer => {
|
||||
let (username, email) = opt_authed
|
||||
.as_ref()
|
||||
.map(|a| (a.username.clone(), a.email.clone()))
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest("Required to be authed in viewer mode".to_string())
|
||||
})?;
|
||||
(
|
||||
username.clone(),
|
||||
username_to_permissioned_as(&username),
|
||||
email,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok((username, permissioned_as, email))
|
||||
}
|
||||
|
||||
async fn execute_component(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1129,6 +1193,7 @@ async fn execute_component(
|
||||
triggerables_v2: Some(hm),
|
||||
on_behalf_of: None,
|
||||
on_behalf_of_email: None,
|
||||
s3_inputs: None,
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -1146,41 +1211,8 @@ async fn execute_component(
|
||||
}
|
||||
};
|
||||
|
||||
let (username, permissioned_as, email) = match policy.execution_mode {
|
||||
ExecutionMode::Anonymous => {
|
||||
let username = opt_authed
|
||||
.as_ref()
|
||||
.map(|a| a.username.clone())
|
||||
.unwrap_or_else(|| "anonymous".to_string());
|
||||
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
|
||||
(username, permissioned_as, email)
|
||||
}
|
||||
ExecutionMode::Publisher => {
|
||||
let username = opt_authed
|
||||
.as_ref()
|
||||
.map(|a| a.username.clone())
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(
|
||||
"publisher execution mode requires authentication".to_string(),
|
||||
)
|
||||
})?;
|
||||
let (permissioned_as, email) = get_on_behalf_of(&policy)?;
|
||||
(username, permissioned_as, email)
|
||||
}
|
||||
ExecutionMode::Viewer => {
|
||||
let (username, email) = opt_authed
|
||||
.as_ref()
|
||||
.map(|a| (a.username.clone(), a.email.clone()))
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest("Required to be authed in viewer mode".to_string())
|
||||
})?;
|
||||
(
|
||||
username.clone(),
|
||||
username_to_permissioned_as(&username),
|
||||
email,
|
||||
)
|
||||
}
|
||||
};
|
||||
let (username, permissioned_as, email) =
|
||||
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
|
||||
|
||||
let (job_payload, (args, job_id), tag) = match payload {
|
||||
ExecuteApp { args, component, raw_code: Some(raw_code), path: None, .. } => {
|
||||
@@ -1249,6 +1281,248 @@ async fn execute_component(
|
||||
Ok(uuid.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "parquet"))]
|
||||
async fn upload_s3_file_from_app() -> Result<()> {
|
||||
return Err(Error::BadRequest(
|
||||
"This endpoint requires the parquet feature to be enabled".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct UploadFileToS3Query {
|
||||
file_key: Option<String>,
|
||||
file_extension: Option<String>,
|
||||
s3_resource_path: Option<String>,
|
||||
content_type: Option<String>,
|
||||
content_disposition: Option<String>,
|
||||
force_viewer_file_key_regex: Option<String>,
|
||||
force_viewer_allow_user_resources: Option<bool>,
|
||||
force_viewer_allow_workspace_resource: Option<bool>,
|
||||
force_viewer_allowed_resources: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
async fn upload_s3_file_from_app(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Query(query): Query<UploadFileToS3Query>,
|
||||
request: axum::extract::Request,
|
||||
) -> JsonResult<UploadFileResponse> {
|
||||
let policy = if let Some(file_key_regex) = query.force_viewer_file_key_regex {
|
||||
Some(Policy {
|
||||
execution_mode: ExecutionMode::Viewer,
|
||||
triggerables: None,
|
||||
triggerables_v2: None,
|
||||
on_behalf_of: None,
|
||||
on_behalf_of_email: None,
|
||||
s3_inputs: Some(vec![S3Input {
|
||||
file_key_regex: file_key_regex,
|
||||
allow_user_resources: query.force_viewer_allow_user_resources.unwrap_or(false),
|
||||
allow_workspace_resource: query
|
||||
.force_viewer_allow_workspace_resource
|
||||
.unwrap_or(false),
|
||||
allowed_resources: query
|
||||
.force_viewer_allowed_resources
|
||||
.map(|s| s.split(',').map(|s| s.to_string()).collect())
|
||||
.unwrap_or_default(),
|
||||
}]),
|
||||
})
|
||||
} else {
|
||||
let policy_o = sqlx::query_scalar!(
|
||||
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
|
||||
&path.0,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
policy_o
|
||||
.map(|p| serde_json::from_value::<Policy>(p).map_err(to_anyhow))
|
||||
.transpose()?
|
||||
};
|
||||
|
||||
let user_db = UserDB::new(db.clone());
|
||||
|
||||
let (s3_resource_opt, file_key) = if policy.as_ref().is_some_and(|p| p.s3_inputs.is_some()) {
|
||||
let policy = policy.unwrap();
|
||||
let s3_inputs = policy.s3_inputs.as_ref().unwrap();
|
||||
|
||||
let (username, permissioned_as, email) =
|
||||
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed).await?;
|
||||
|
||||
let on_behalf_authed =
|
||||
fetch_api_authed_from_permissioned_as(permissioned_as, email, &w_id, &db, username)
|
||||
.await?;
|
||||
|
||||
if let Some(file_key) = query.file_key {
|
||||
// file key is provided => requires workspace, user or list policy and must match the regex
|
||||
let matching_s3_inputs = if let Some(ref s3_resource_path) = query.s3_resource_path {
|
||||
s3_inputs
|
||||
.iter()
|
||||
.filter(|s3_input| {
|
||||
s3_input.allowed_resources.contains(s3_resource_path)
|
||||
|| s3_input.allow_user_resources
|
||||
})
|
||||
.sorted_by_key(|i| i.allow_user_resources) // consider user resources last
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
s3_inputs
|
||||
.iter()
|
||||
.filter(|s3_input| s3_input.allow_workspace_resource)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
let matched_input = matching_s3_inputs.iter().find(|s3_input| {
|
||||
match Regex::new(&s3_input.file_key_regex) {
|
||||
Ok(re) => re.is_match(&file_key),
|
||||
Err(e) => {
|
||||
tracing::error!("Error compiling regex: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(matched_input) = matched_input {
|
||||
if let Some(ref s3_resource_path) = query.s3_resource_path {
|
||||
if matched_input.allow_user_resources {
|
||||
if let Some(authed) = opt_authed {
|
||||
(
|
||||
Some(
|
||||
get_s3_resource(
|
||||
&authed,
|
||||
&db,
|
||||
Some(user_db),
|
||||
"",
|
||||
&w_id,
|
||||
s3_resource_path,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
file_key,
|
||||
)
|
||||
} else {
|
||||
return Err(Error::BadRequest(
|
||||
"User resources are not allowed without being logged in"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
(
|
||||
Some(
|
||||
get_s3_resource(
|
||||
&on_behalf_authed,
|
||||
&db,
|
||||
Some(user_db),
|
||||
"",
|
||||
&w_id,
|
||||
s3_resource_path,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
file_key,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let (_, s3_resource_opt) =
|
||||
get_workspace_s3_resource(&on_behalf_authed, &db, None, "", &w_id, None)
|
||||
.await?;
|
||||
(s3_resource_opt, file_key)
|
||||
}
|
||||
} else {
|
||||
return Err(Error::BadRequest(
|
||||
"No matching s3 resource found for the given file key".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// no file key => requires unnamed upload policy => allow workspace resource and file_key_regex is empty
|
||||
let has_unnamed_policy = s3_inputs.iter().any(|s3_input| {
|
||||
s3_input.allow_workspace_resource && s3_input.file_key_regex.is_empty()
|
||||
});
|
||||
|
||||
if !has_unnamed_policy {
|
||||
return Err(Error::BadRequest(
|
||||
"no policy found for unnamed s3 file uplooad".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// for now, we place all files into `windmill_uploads` folder with a random name
|
||||
// TODO: make the folder configurable via the workspace settings
|
||||
let file_key = get_random_file_name(query.file_extension);
|
||||
|
||||
let (_, s3_resource_opt) =
|
||||
get_workspace_s3_resource(&on_behalf_authed, &db, None, "", &w_id, None).await?;
|
||||
|
||||
(s3_resource_opt, file_key)
|
||||
}
|
||||
} else {
|
||||
// backward compatibility (no policy)
|
||||
// if no policy but logged in, use the user's auth to get the s3 resource
|
||||
if let Some(authed) = opt_authed {
|
||||
let file_key = query
|
||||
.file_key
|
||||
.unwrap_or_else(|| get_random_file_name(query.file_extension));
|
||||
|
||||
if let Some(ref s3_resource_path) = query.s3_resource_path {
|
||||
(
|
||||
Some(
|
||||
get_s3_resource(
|
||||
&authed,
|
||||
&db,
|
||||
Some(user_db),
|
||||
"",
|
||||
&w_id,
|
||||
s3_resource_path,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
file_key,
|
||||
)
|
||||
} else {
|
||||
let (_, s3_resource) =
|
||||
get_workspace_s3_resource(&authed, &db, None, "", &w_id, None).await?;
|
||||
|
||||
(s3_resource, file_key)
|
||||
}
|
||||
} else {
|
||||
return Err(Error::BadRequest("Missing s3 policy".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let s3_resource = s3_resource_opt.ok_or(Error::InternalErr(
|
||||
"No files storage resource defined at the workspace level".to_string(),
|
||||
))?;
|
||||
let s3_client = build_object_store_client(&s3_resource).await?;
|
||||
|
||||
let options = Attributes::from_iter(vec![
|
||||
(
|
||||
Attribute::ContentType,
|
||||
query.content_type.unwrap_or_else(|| {
|
||||
mime_guess::from_path(&file_key)
|
||||
.first_or_octet_stream()
|
||||
.to_string()
|
||||
}),
|
||||
),
|
||||
(
|
||||
Attribute::ContentDisposition,
|
||||
query.content_disposition.unwrap_or("inline".to_string()),
|
||||
),
|
||||
])
|
||||
.into();
|
||||
|
||||
upload_file_internal(s3_client, &file_key, request, options).await?;
|
||||
|
||||
return Ok(Json(UploadFileResponse { file_key }));
|
||||
}
|
||||
|
||||
fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
|
||||
let permissioned_as = policy
|
||||
.on_behalf_of
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
use axum::Router;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::s3_helpers::StorageResourceType;
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
#[cfg(feature = "parquet")]
|
||||
use object_store::{ObjectStore, PutMultipartOpts};
|
||||
#[cfg(feature = "parquet")]
|
||||
use std::sync::Arc;
|
||||
use windmill_common::error;
|
||||
#[cfg(feature = "parquet")]
|
||||
use windmill_common::{db::UserDB, s3_helpers::ObjectStoreResource};
|
||||
#[derive(Serialize)]
|
||||
pub struct UploadFileResponse {
|
||||
pub file_key: String,
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
@@ -21,3 +33,29 @@ pub async fn get_workspace_s3_resource<'c>(
|
||||
// implementation is not open source
|
||||
Ok((None, None))
|
||||
}
|
||||
|
||||
pub fn get_random_file_name(_file_extension: Option<String>) -> String {
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn get_s3_resource<'c>(
|
||||
_authed: &ApiAuthed,
|
||||
_db: &DB,
|
||||
_user_db: Option<UserDB>,
|
||||
_token: &str,
|
||||
_w_id: &str,
|
||||
_resource_path: &str,
|
||||
_resource_type: Option<StorageResourceType>,
|
||||
_job_id: Option<Uuid>,
|
||||
) -> error::Result<ObjectStoreResource> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn upload_file_internal(
|
||||
_s3_client: Arc<dyn ObjectStore>,
|
||||
_file_key: &str,
|
||||
_request: axum::extract::Request,
|
||||
_options: PutMultipartOpts,
|
||||
) -> error::Result<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
@@ -746,10 +746,20 @@ pub async fn fetch_api_authed(
|
||||
username_override: String,
|
||||
) -> error::Result<ApiAuthed> {
|
||||
let permissioned_as = username_to_permissioned_as(username.as_str());
|
||||
fetch_api_authed_from_permissioned_as(permissioned_as, email, w_id, db, username_override).await
|
||||
}
|
||||
|
||||
pub async fn fetch_api_authed_from_permissioned_as(
|
||||
permissioned_as: String,
|
||||
email: String,
|
||||
w_id: &str,
|
||||
db: &DB,
|
||||
username_override: String,
|
||||
) -> error::Result<ApiAuthed> {
|
||||
let authed =
|
||||
fetch_authed_from_permissioned_as(permissioned_as, email.clone(), w_id, db).await?;
|
||||
Ok(ApiAuthed {
|
||||
username: username,
|
||||
username: authed.username,
|
||||
email: email,
|
||||
is_admin: authed.is_admin,
|
||||
is_operator: authed.is_operator,
|
||||
|
||||
@@ -61,6 +61,18 @@
|
||||
export let render = true
|
||||
export let title: string | undefined = undefined
|
||||
export let placeholder: string | undefined = undefined
|
||||
export let appPath: string | undefined = undefined
|
||||
export let computeS3ForceViewerPolicies:
|
||||
| (() =>
|
||||
| {
|
||||
allowed_resources: string[]
|
||||
allow_user_resources: boolean
|
||||
allow_workspace_resource: boolean
|
||||
file_key_regex: string
|
||||
}
|
||||
| undefined)
|
||||
| undefined = undefined
|
||||
export let workspace: string | undefined = undefined
|
||||
|
||||
let oneOfSelected: string | undefined = undefined
|
||||
async function updateOneOfSelected(oneOf: SchemaProperty[] | undefined) {
|
||||
@@ -428,6 +440,9 @@
|
||||
.toLowerCase() == 's3object'}
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<FileUpload
|
||||
{appPath}
|
||||
computeForceViewerPolicies={computeS3ForceViewerPolicies}
|
||||
{workspace}
|
||||
allowMultiple={false}
|
||||
randomFileKey={true}
|
||||
on:addition={(evt) => {
|
||||
|
||||
@@ -16,6 +16,18 @@
|
||||
export let defaultValues: Record<string, any> = {}
|
||||
export let dynamicEnums: Record<string, any> = {}
|
||||
export let disabled: boolean = false
|
||||
export let appPath: string | undefined = undefined
|
||||
export let computeS3ForceViewerPolicies:
|
||||
| (() =>
|
||||
| {
|
||||
allowed_resources: string[]
|
||||
allow_user_resources: boolean
|
||||
allow_workspace_resource: boolean
|
||||
file_key_regex: string
|
||||
}
|
||||
| undefined)
|
||||
| undefined = undefined
|
||||
export let workspace: string | undefined = undefined
|
||||
|
||||
let inputCheck: { [id: string]: boolean } = {}
|
||||
let errors: { [id: string]: string } = {}
|
||||
@@ -104,6 +116,9 @@
|
||||
{displayType}
|
||||
{css}
|
||||
disabled={disabled || schema.properties[argName].disabled}
|
||||
{appPath}
|
||||
{computeS3ForceViewerPolicies}
|
||||
{workspace}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
import ResolveConfig from '../helpers/ResolveConfig.svelte'
|
||||
import ResolveStyle from '../helpers/ResolveStyle.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { computeWorkspaceS3FileInputPolicy } from '../../editor/appUtilsS3'
|
||||
import { defaultIfEmptyString } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
export let id: string
|
||||
export let componentInput: AppInput | undefined
|
||||
@@ -26,8 +29,16 @@
|
||||
export let configuration: RichConfigurations
|
||||
export let customCss: ComponentCustomCSS<'schemaformcomponent'> | undefined = undefined
|
||||
|
||||
const { worldStore, connectingInput, app, selectedComponent, componentControl } =
|
||||
getContext<AppViewerContext>('AppViewerContext')
|
||||
const {
|
||||
worldStore,
|
||||
connectingInput,
|
||||
app,
|
||||
selectedComponent,
|
||||
componentControl,
|
||||
appPath,
|
||||
isEditor,
|
||||
workspace
|
||||
} = getContext<AppViewerContext>('AppViewerContext')
|
||||
const iterContext = getContext<ListContext>('ListWrapperContext')
|
||||
const listInputs: ListInputs | undefined = getContext<ListInputs>('ListInputs')
|
||||
|
||||
@@ -100,6 +111,14 @@
|
||||
previousDefault = structuredClone(resolvedConfig.defaultValues)
|
||||
args = previousDefault ?? {}
|
||||
}
|
||||
|
||||
function computeS3ForceViewerPolicies() {
|
||||
if (!isEditor) {
|
||||
return undefined
|
||||
}
|
||||
const policy = computeWorkspaceS3FileInputPolicy()
|
||||
return policy
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each Object.keys(components['schemaformcomponent'].initialData.configuration) as key (key)}
|
||||
@@ -140,6 +159,9 @@
|
||||
bind:this={schemaForm}
|
||||
displayType={Boolean(resolvedConfig.displayType)}
|
||||
largeGap={Boolean(resolvedConfig.largeGap)}
|
||||
appPath={defaultIfEmptyString(appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`)}
|
||||
{computeS3ForceViewerPolicies}
|
||||
{workspace}
|
||||
{css}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { get } from 'svelte/store'
|
||||
import RefreshButton from '$lib/components/apps/components/helpers/RefreshButton.svelte'
|
||||
import { ctxRegex } from '../../utils'
|
||||
import { computeWorkspaceS3FileInputPolicy } from '../../editor/appUtilsS3'
|
||||
|
||||
// Component props
|
||||
export let id: string
|
||||
@@ -671,6 +672,14 @@
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function computeS3ForceViewerPolicies() {
|
||||
if (!isEditor) {
|
||||
return undefined
|
||||
}
|
||||
const policy = computeWorkspaceS3FileInputPolicy()
|
||||
return policy
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each Object.entries(fields ?? {}) as [key, v] (key)}
|
||||
@@ -754,6 +763,9 @@
|
||||
<div class="px-2 h-fit min-h-0">
|
||||
<LightweightSchemaForm
|
||||
schema={schemaStripped}
|
||||
appPath={defaultIfEmptyString(appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`)}
|
||||
{computeS3ForceViewerPolicies}
|
||||
{workspace}
|
||||
bind:this={schemaForm}
|
||||
bind:args
|
||||
on:inputClicked={handleInputClick}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
import FileUpload from '$lib/components/common/fileUpload/FileUpload.svelte'
|
||||
import InitializeComponent from '../helpers/InitializeComponent.svelte'
|
||||
import { computeS3FileInputPolicy } from '../../editor/appUtilsS3'
|
||||
import { defaultIfEmptyString } from '$lib/utils'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
export let id: string
|
||||
export let configuration: RichConfigurations
|
||||
@@ -77,6 +80,15 @@
|
||||
{/if}
|
||||
*/
|
||||
let forceDisplayUploads: boolean = false
|
||||
|
||||
const { appPath, isEditor } = getContext<AppViewerContext>('AppViewerContext')
|
||||
function computeForceViewerPolicies() {
|
||||
if (!isEditor) {
|
||||
return undefined
|
||||
}
|
||||
const policy = computeS3FileInputPolicy((configuration as any)?.type?.configuration?.s3, $app)
|
||||
return policy
|
||||
}
|
||||
</script>
|
||||
|
||||
<InitializeComponent {id} />
|
||||
@@ -139,5 +151,7 @@
|
||||
outputs.result.set(value)
|
||||
}}
|
||||
{forceDisplayUploads}
|
||||
appPath={defaultIfEmptyString(appPath, `u/${$userStore?.username ?? 'unknown'}/newapp`)}
|
||||
{computeForceViewerPolicies}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
import ToggleEnable from '$lib/components/common/toggleButton-v2/ToggleEnable.svelte'
|
||||
import HideButton from './settingsPanel/HideButton.svelte'
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
import { computeS3FileInputPolicy, computeWorkspaceS3FileInputPolicy } from './appUtilsS3'
|
||||
|
||||
async function hash(message) {
|
||||
try {
|
||||
@@ -195,8 +196,12 @@
|
||||
}
|
||||
|
||||
async function computeTriggerables() {
|
||||
const items = allItems($app.grid, $app.subgrids)
|
||||
|
||||
console.log('items', items)
|
||||
|
||||
const allTriggers: ([string, TriggerableV2] | undefined)[] = (await Promise.all(
|
||||
allItems($app.grid, $app.subgrids)
|
||||
items
|
||||
.flatMap((x) => {
|
||||
let c = x.data as AppComponent
|
||||
let r: { input: AppInput | undefined; id: string }[] = [
|
||||
@@ -296,6 +301,44 @@
|
||||
allTriggers.filter(Boolean) as [string, TriggerableV2][]
|
||||
)
|
||||
policy.triggerables_v2 = ntriggerables
|
||||
|
||||
const s3_inputs = items
|
||||
.filter((x) => (x.data as AppComponent).type === 's3fileinputcomponent')
|
||||
.map((x) => {
|
||||
const c = x.data as AppComponent
|
||||
const config = c.configuration as any
|
||||
return computeS3FileInputPolicy(config?.type?.configuration?.s3, $app)
|
||||
})
|
||||
.filter(Boolean) as {
|
||||
allowed_resources: string[]
|
||||
allow_user_resources: boolean
|
||||
file_key_regex: string
|
||||
}[]
|
||||
|
||||
if (
|
||||
items.findIndex((x) => {
|
||||
const c = x.data as AppComponent
|
||||
if (c.type === 'schemaformcomponent') {
|
||||
return (
|
||||
Object.values((c.componentInput as any)?.value?.properties ?? {}).findIndex(
|
||||
(p: any) => p?.type === 'object' && p?.format === 'resource-s3_object'
|
||||
) !== -1
|
||||
)
|
||||
} else if (c.type === 'formbuttoncomponent' || c.type === 'formcomponent') {
|
||||
return (
|
||||
Object.values((c.componentInput as any)?.fields ?? {}).findIndex(
|
||||
(p: any) => p?.fieldType === 'object' && p?.format === 'resource-s3_object'
|
||||
) !== -1
|
||||
)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}) !== -1
|
||||
) {
|
||||
s3_inputs.push(computeWorkspaceS3FileInputPolicy())
|
||||
}
|
||||
|
||||
policy.s3_inputs = s3_inputs
|
||||
}
|
||||
|
||||
async function processRunnable(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { AppInput, EvalInputV2 } from '../inputType'
|
||||
import type { App } from '../types'
|
||||
import { collectOneOfFields } from './appUtils'
|
||||
|
||||
function filenameExprToRegex(template: string) {
|
||||
const filenameEscaped = template.replaceAll('${file.name}', '<file_name>') // replace filename with placeholder
|
||||
const escapedTemplate = filenameEscaped
|
||||
.slice(1, -1) // remove quotes
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex special characters
|
||||
const regexPattern = escapedTemplate.replaceAll('<file_name>', '[^/]+') // replace filename placeholder with regex pattern
|
||||
return `^${regexPattern}$`
|
||||
}
|
||||
|
||||
function staticToRegex(str: string) {
|
||||
return `^${str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`
|
||||
}
|
||||
|
||||
function checkIfExprIsString(input: string) {
|
||||
return /^(['"`])[^'"`]*\1$/g.test(input)
|
||||
}
|
||||
|
||||
function checkIfEvalIsStringWithFilename(input: EvalInputV2) {
|
||||
if (input.connections.length > 0) {
|
||||
return false
|
||||
} else {
|
||||
return checkIfExprIsString(input.expr.replaceAll('${file.name}', ''))
|
||||
}
|
||||
}
|
||||
|
||||
function removeResourcePrefix(resource: string) {
|
||||
return resource.replace(/^\$res:/, '')
|
||||
}
|
||||
|
||||
export function computeWorkspaceS3FileInputPolicy() {
|
||||
return {
|
||||
allow_workspace_resource: true,
|
||||
allowed_resources: [],
|
||||
allow_user_resources: false,
|
||||
file_key_regex: ''
|
||||
}
|
||||
}
|
||||
|
||||
export function computeS3FileInputPolicy(s3Config: any, app: App) {
|
||||
const resourceInput = s3Config?.resource as AppInput | undefined
|
||||
const pathTemplateInput = s3Config?.pathTemplate as AppInput | undefined
|
||||
|
||||
const allow_workspace_resource =
|
||||
!resourceInput || (resourceInput.type === 'static' && !resourceInput.value)
|
||||
const allowed_resources: string[] = resourceInput
|
||||
? resourceInput.type === 'static'
|
||||
? resourceInput.value
|
||||
? [removeResourcePrefix(resourceInput.value)]
|
||||
: []
|
||||
: collectOneOfFields(
|
||||
{
|
||||
s3_resource: resourceInput
|
||||
},
|
||||
app
|
||||
).s3_resource?.map((s) => removeResourcePrefix(s)) ?? []
|
||||
: []
|
||||
|
||||
const allow_user_resources =
|
||||
(resourceInput?.type === 'evalv2' && resourceInput?.allowUserResources) ?? false
|
||||
let file_key_regex = '^.*$'
|
||||
if (pathTemplateInput) {
|
||||
if (pathTemplateInput.type === 'static') {
|
||||
file_key_regex = staticToRegex(pathTemplateInput.value)
|
||||
} else if (
|
||||
pathTemplateInput.type === 'evalv2' &&
|
||||
checkIfEvalIsStringWithFilename(pathTemplateInput)
|
||||
) {
|
||||
file_key_regex = filenameExprToRegex(pathTemplateInput.expr)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allow_workspace_resource,
|
||||
allowed_resources,
|
||||
allow_user_resources,
|
||||
file_key_regex
|
||||
}
|
||||
}
|
||||
@@ -193,7 +193,7 @@
|
||||
{:else if componentInput?.type === 'user'}
|
||||
<span class="text-2xs italic text-tertiary">Field's value is set by the user</span>
|
||||
{/if}
|
||||
{#if (componentInput?.type === 'evalv2' || componentInput?.type === 'connected' || componentInput?.type === 'user') && fieldType == 'object' && format?.startsWith('resource-')}
|
||||
{#if (componentInput?.type === 'evalv2' || componentInput?.type === 'connected' || componentInput?.type === 'user') && ((fieldType == 'object' && format?.startsWith('resource-') && format !== 'resource-s3_object') || fieldType == 'resource')}
|
||||
<div class="flex flex-row items-center">
|
||||
<Toggle
|
||||
size="xs"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { HelpersService } from '$lib/gen'
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
import { Ban, CheckCheck, FileWarning, Files, RefreshCcw, Trash } from 'lucide-svelte'
|
||||
@@ -27,6 +27,17 @@
|
||||
export let defaultValue: string | undefined = undefined
|
||||
export let workspace: string | undefined = undefined
|
||||
export let fileUploads: Writable<FileUploadData[]> = writable([])
|
||||
export let appPath: string | undefined = undefined
|
||||
export let computeForceViewerPolicies:
|
||||
| (() =>
|
||||
| {
|
||||
allowed_resources: string[]
|
||||
allow_user_resources: boolean
|
||||
allow_workspace_resource: boolean
|
||||
file_key_regex: string
|
||||
}
|
||||
| undefined)
|
||||
| undefined = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -115,6 +126,26 @@
|
||||
params.append('content_type', fileToUpload.type)
|
||||
}
|
||||
|
||||
if (computeForceViewerPolicies !== undefined) {
|
||||
const forceViewerPolicies = computeForceViewerPolicies()
|
||||
|
||||
if (forceViewerPolicies) {
|
||||
params.append(
|
||||
'force_viewer_allowed_resources',
|
||||
forceViewerPolicies.allowed_resources.join(',')
|
||||
)
|
||||
params.append(
|
||||
'force_viewer_allow_user_resources',
|
||||
JSON.stringify(forceViewerPolicies.allow_user_resources)
|
||||
)
|
||||
params.append(
|
||||
'force_viewer_allow_workspace_resource',
|
||||
JSON.stringify(forceViewerPolicies.allow_workspace_resource)
|
||||
)
|
||||
params.append('force_viewer_file_key_regex', forceViewerPolicies.file_key_regex)
|
||||
}
|
||||
}
|
||||
|
||||
// let response = await fetch(
|
||||
// `/api/w/${$workspaceStore}/job_helpers/multipart_upload_s3_file?${params.toString()}`,
|
||||
// {
|
||||
@@ -158,9 +189,16 @@
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
xhr?.open(
|
||||
'POST',
|
||||
`/api/w/${workspace ?? $workspaceStore}/job_helpers/upload_s3_file?${params.toString()}`,
|
||||
appPath
|
||||
? `/api/w/${
|
||||
workspace ?? $workspaceStore
|
||||
}/apps_u/upload_s3_file/${appPath}?${params.toString()}`
|
||||
: `/api/w/${
|
||||
workspace ?? $workspaceStore
|
||||
}/job_helpers/upload_s3_file?${params.toString()}`,
|
||||
true
|
||||
)
|
||||
xhr?.setRequestHeader('Content-Type', 'application/octet-stream')
|
||||
@@ -309,7 +347,7 @@
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
{#if fileUpload.progress === 100 && !fileUpload.cancelled}
|
||||
{#if fileUpload.progress === 100 && !fileUpload.cancelled && $userStore}
|
||||
<Button
|
||||
size="xs2"
|
||||
color="red"
|
||||
|
||||
Reference in New Issue
Block a user