diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 91e7b06ecc..2f9a96a0d3 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - version: 1.246.15 + version: 1.249.0 title: Windmill API contact: name: Windmill Team @@ -7800,6 +7800,8 @@ paths: completed: type: boolean result: {} + success: + type: boolean started: type: boolean required: @@ -10429,10 +10431,36 @@ paths: - Csv - Parquet - Unknown - download_url: - type: string required: &ref_175 - content_type + /w/{workspace}/job_helpers/generate_download_url: + get: + summary: Generate a unique URL to download the file + operationId: generateDownloadUrl + tags: + - helpers + parameters: + - name: workspace + in: path + required: true + schema: *ref_0 + - name: file_key + in: query + required: true + schema: + type: string + responses: + '200': + description: Download URL + content: + application/json: + schema: + type: object + properties: + download_url: + type: string + required: + - download_url /w/{workspace}/job_helpers/delete_s3_file: delete: summary: Permanently delete file from S3 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ef90b51022..1d4a26267c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -7009,6 +7009,32 @@ paths: schema: $ref: "#/components/schemas/WindmillFilePreview" + /w/{workspace}/job_helpers/generate_download_url: + get: + summary: Generate a unique URL to download the file + operationId: generateDownloadUrl + tags: + - helpers + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: file_key + in: query + required: true + schema: + type: string + responses: + "200": + description: Download URL + content: + application/json: + schema: + type: object + properties: + download_url: + type: string + required: + - download_url + /w/{workspace}/job_helpers/delete_s3_file: delete: summary: Permanently delete file from S3 @@ -9281,8 +9307,6 @@ components: content_type: type: string enum: ["RawText", "Csv", "Parquet", "Unknown"] - download_url: - type: string required: - content_type diff --git a/backend/windmill-api/src/job_helpers.rs b/backend/windmill-api/src/job_helpers.rs index 5ceb0cf0b8..656677c520 100644 --- a/backend/windmill-api/src/job_helpers.rs +++ b/backend/windmill-api/src/job_helpers.rs @@ -29,6 +29,7 @@ use polars::{ }; use serde::{Deserialize, Serialize}; use tower_http::cors::{Any, CorsLayer}; +use windmill_common::error::JsonResult; use windmill_common::{ db::UserDB, error, @@ -77,6 +78,10 @@ pub fn workspaced_service() -> Router { "/load_file_preview", get(load_file_preview).layer(cors.clone()), ) + .route( + "/generate_download_url", + get(generate_download_url).layer(cors.clone()), + ) .route( "/delete_s3_file", delete(delete_s3_file).layer(cors.clone()), @@ -450,7 +455,6 @@ struct LoadFilePreviewResponse { pub content: Option, pub content_type: WindmillContentType, pub msg: Option, - pub download_url: Option, } #[derive(Serialize)] @@ -543,19 +547,6 @@ async fn load_file_preview( ) }; - // URL expires 30 minutes after its generation - let presigned_config = PresigningConfig::expires_in(Duration::from_secs(60 * 30)) - .map_err(|err| error::Error::InternalErr(err.to_string()))?; - let download_url = s3_client - .get_object() - .bucket(&s3_bucket) - .key(&file_key) - .presigned(presigned_config) - .await - .map_err(|err| error::Error::InternalErr(err.to_string()))? - .uri() - .to_string(); - let file_chunk_length = if s3_object_content_length.is_some() { cmp::min( query.read_bytes_length, @@ -645,20 +636,61 @@ async fn load_file_preview( content_type: content_type, content: Some(content), msg: None, - download_url: Some(download_url), }, Err(err) => LoadFilePreviewResponse { content_type: content_type, content: None, msg: Some(err.to_string()), - download_url: Some(download_url), }, }; return Ok(Json(response)); } +#[derive(Deserialize)] +struct GenerateDownloadUrlQuery { + pub file_key: String, +} + +#[derive(Serialize)] +struct GenerateDownloadUrlResponse { + pub download_url: String, +} + +async fn generate_download_url( + authed: ApiAuthed, + Extension(db): Extension, + Tokened { token }: Tokened, + Path(w_id): Path, + Query(query): Query, +) -> JsonResult { + let file_key = query.file_key.clone(); + let s3_resource_opt = get_workspace_s3_resource(&authed, &db, None, &token, &w_id).await?; + + let s3_resource = s3_resource_opt.ok_or(error::Error::InternalErr( + "No files storage resource defined at the workspace level".to_string(), + ))?; + + let s3_client = build_s3_client(&s3_resource); + let s3_bucket = s3_resource.bucket.clone(); + + // URL expires 5 minutes after its generation + let presigned_config = PresigningConfig::expires_in(Duration::from_secs(60 * 5)) + .map_err(|err| error::Error::InternalErr(err.to_string()))?; + let download_url = s3_client + .get_object() + .bucket(&s3_bucket) + .key(&file_key) + .presigned(presigned_config) + .await + .map_err(|err| error::Error::InternalErr(err.to_string()))? + .uri() + .to_string(); + + return Ok(Json(GenerateDownloadUrlResponse { download_url })); +} + #[derive(Deserialize)] struct DeleteS3FileQuery { pub file_key: String, diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index dbcd35736b..d0844b2ac0 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -2,7 +2,7 @@ import { Highlight } from 'svelte-highlight' import { json } from 'svelte-highlight/languages' import TableCustom from './TableCustom.svelte' - import { copyToClipboard, roughSizeOfObject, truncate } from '$lib/utils' + import { copyToClipboard, emptyString, roughSizeOfObject, truncate } from '$lib/utils' import { Button, Drawer, DrawerContent } from './common' import { ClipboardCopy, Download, Expand, PanelRightOpen, Table2 } from 'lucide-svelte' import Portal from 'svelte-portal' @@ -10,6 +10,8 @@ import S3FilePicker from './S3FilePicker.svelte' import AutoDataTable from './table/AutoDataTable.svelte' import Markdown from 'svelte-exmarkdown' + import { HelpersService } from '$lib/gen' + import { workspaceStore } from '$lib/stores' export let result: any export let requireHtmlApproval = false @@ -199,6 +201,18 @@ return result } + + async function downloadS3File(fileKey: string | undefined) { + if (emptyString(fileKey)) { + return + } + const downloadUrl = await HelpersService.generateDownloadUrl({ + workspace: $workspaceStore!, + fileKey: fileKey! + }) + console.log('download URL ', downloadUrl.download_url) + window.open(downloadUrl.download_url, '_blank') + }
@@ -347,6 +361,13 @@ {:else if !forceJson && resultKind == 's3object'}
+