From 48618cff8c35a345babd12844653f83addbcd7e8 Mon Sep 17 00:00:00 2001 From: Tristan TR <69242752+tristantr@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:19:25 +0200 Subject: [PATCH] feat: Add image when publishing a project (#10310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(hub): remove per-item Publish to Hub entry points Publishing to the Hub now happens exclusively through the folder-level deploy-to-hub flow (/folders). Remove the standalone entry points: - script detail page menu item (and the SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB const that gated it) - script list row dropdown item - raw app editor menu item, its zip-download drawer and publishToHub() - long-dead commented block in AppEditorHeader Also drop the now-orphaned URL helpers (scriptToHubUrl, flowToHubUrl, appToHubUrl, rawAppToHubUrl) from lib/hub.ts. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(hub): upload a custom project logo from the deploy-to-hub drawer Add a Logo field to the bundle metadata form: a drag-and-drop dropzone (png/svg, 512KB client-side cap mirrored server-side by the Hub) that turns into a live replica of the Hub project card once an image is picked, so the logo can be judged in context before publishing. The logo is pushed after the draft's items/migrations via the new POST /projects/{slug}/logo proxy in hub_publish.rs (slug validated by construction, `logo: null` forwarded to clear). Leaving the field empty never touches the Hub's existing logo, so re-publishing a bundle keeps it. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(hub): logo removal, safer mime inference, explicit clear semantics Review follow-ups on the project logo upload: - Removing a published logo is now possible: hubLogo is three-state (undefined = leave the Hub's logo alone, null = clear on publish, object = upload). Rehydration reads has_logo so the drawer shows a "Remove on publish" affordance when the Hub already has one, with an undo banner before publishing. - hub_publish.rs uses a double-Option for the logo field: a missing `logo` key is now a 400 instead of being serialized as `logo: null`, which the Hub interprets as an explicit clear — POSTing `{}` can no longer silently delete a project's logo. - Client mime inference prefers the browser-reported file.type over the filename extension, so a PNG misnamed *.svg no longer produces a broken preview and a guaranteed server-side sniff rejection. Co-Authored-By: Claude Opus 4.8 (1M context) * Update frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update frontend/src/lib/components/workspaceSettings/DeployToHub.svelte Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix(hub): validate logo size/mime/base64 in the proxy, document the endpoint - Enforce the logo constraints in windmill-api itself instead of relying on the browser and remote Hub: a route-level DefaultBodyLimit sized for a max logo in base64 (+JSON envelope) overrides the global request limit, and the handler validates the mime allowlist, base64 alphabet and decoded length (512KB cap) before anything is forwarded. - Add /w/{workspace}/hub/projects/{slug}/logo to openapi.yaml (with the ProjectLogoBody schema) and regenerate the frontend client. - Drop a narrating comment on the hidden file input. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- backend/windmill-api/openapi.yaml | 56 ++++++ backend/windmill-api/src/hub_publish.rs | 81 ++++++++- .../apps/editor/AppEditorHeader.svelte | 9 - .../components/common/table/ScriptRow.svelte | 27 +-- .../raw_apps/RawAppEditorHeader.svelte | 81 --------- .../workspaceSettings/DeployToHub.svelte | 166 ++++++++++++++++++ .../deployToHubSession.svelte.ts | 25 +++ frontend/src/lib/consts.ts | 2 - frontend/src/lib/hub.ts | 46 +---- .../scripts/get/[...hash]/+page.svelte | 28 --- 10 files changed, 329 insertions(+), 192 deletions(-) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index ea15c8167b..bc6d731756 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -23313,6 +23313,42 @@ paths: schema: type: string + /w/{workspace}/hub/projects/{slug}/logo: + post: + summary: set or clear a hub project's custom logo + description: | + Requires the caller to be a workspace admin. Sets the project's custom + logo (base64 png/svg, decoded size max 512KB) or clears it when `logo` + is null; the `logo` field itself is required so an empty body cannot + clear the logo by accident. Forwards the request to the configured Hub + scoped to the `{workspace}:{folder}` source and returns the Hub's + status code and raw response body. + operationId: publishHubProjectLogo + tags: + - hubPublish + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: slug + in: path + required: true + description: hub project slug + schema: + $ref: "#/components/schemas/HubProjectSlug" + - $ref: "#/components/parameters/HubPublishFolder" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProjectLogoBody" + responses: + "200": + description: raw Hub response body (status code is passed through from the Hub) + content: + text/plain: + schema: + type: string + /w/{workspace}/hub/projects/{slug}/submit: post: summary: submit a hub project draft for review @@ -32495,6 +32531,26 @@ components: recording: type: object + ProjectLogoBody: + type: object + properties: + logo: + description: the logo to set, or null to clear the project's current logo + nullable: true + type: object + properties: + b64: + description: base64-encoded image bytes (decoded size max 512KB) + type: string + mime: + type: string + enum: [image/png, image/svg+xml] + required: + - b64 + - mime + required: + - logo + PublishResourceTypeBody: type: object properties: diff --git a/backend/windmill-api/src/hub_publish.rs b/backend/windmill-api/src/hub_publish.rs index fab473ea41..9c1412cb0b 100644 --- a/backend/windmill-api/src/hub_publish.rs +++ b/backend/windmill-api/src/hub_publish.rs @@ -2,7 +2,7 @@ use crate::auth::Tokened; use crate::db::ApiAuthed; use crate::HTTP_CLIENT; use axum::{ - extract::{FromRequestParts, Json, Path, Query, RawPathParams}, + extract::{DefaultBodyLimit, FromRequestParts, Json, Path, Query, RawPathParams}, http::{request::Parts, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, @@ -32,6 +32,10 @@ pub fn workspaced_service() -> Router { "/projects/{slug}/pipeline_recording", post(publish_pipeline_recording), ) + .route( + "/projects/{slug}/logo", + post(publish_project_logo).layer(DefaultBodyLimit::max(LOGO_BODY_LIMIT)), + ) .route("/resource_types", post(publish_resource_type)) .route("/resources", post(publish_resources)) .route("/triggers", post(publish_triggers)) @@ -359,6 +363,81 @@ async fn publish_pipeline_recording( .await } +#[derive(Deserialize, Serialize)] +struct ProjectLogoInner { + b64: String, + mime: String, +} + +// Custom project logo (png/svg, base64). Double-Option so a missing `logo` +// key is distinguishable from an explicit `logo: null` (which clears the +// logo on the Hub) — otherwise POSTing `{}` would silently delete it. +#[derive(Deserialize, Serialize)] +struct ProjectLogoBody { + #[serde(default, deserialize_with = "deserialize_explicit")] + logo: Option>, +} + +fn deserialize_explicit<'de, D: Deserializer<'de>>( + d: D, +) -> Result>, D::Error> { + Option::::deserialize(d).map(Some) +} + +// Mirrors the Hub's own limits so an oversized/invalid payload is rejected +// here instead of being deserialized, copied and forwarded first. The route +// also carries a DefaultBodyLimit sized for a max logo in base64 + JSON +// envelope, overriding the much larger global request limit. +const MAX_LOGO_BYTES: usize = 512 * 1024; +const LOGO_BODY_LIMIT: usize = MAX_LOGO_BYTES / 3 * 4 + 16 * 1024; +const ALLOWED_LOGO_MIMES: [&str; 2] = ["image/png", "image/svg+xml"]; + +fn validate_logo(inner: &ProjectLogoInner) -> Result<(), Error> { + if !ALLOWED_LOGO_MIMES.contains(&inner.mime.as_str()) { + return Err(Error::BadRequest( + "logo mime must be image/png or image/svg+xml".to_string(), + )); + } + let b = inner.b64.as_bytes(); + let padding = b.iter().rev().take_while(|&&c| c == b'=').count(); + let valid = !b.is_empty() + && b.len() % 4 == 0 + && padding <= 2 + && b[..b.len() - padding] + .iter() + .all(|&c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/'); + if !valid { + return Err(Error::BadRequest("invalid base64".to_string())); + } + if b.len() / 4 * 3 - padding > MAX_LOGO_BYTES { + return Err(Error::BadRequest(format!( + "logo too large (max {}KB)", + MAX_LOGO_BYTES / 1024 + ))); + } + Ok(()) +} + +async fn publish_project_logo( + ctx: HubPublishCtx, + Path((_workspace, slug)): Path<(String, ProjectSlug)>, + Json(body): Json, +) -> Result { + let Some(logo) = body.logo else { + return Err(Error::BadRequest( + "logo field is required: an object to set it, or null to clear it".to_string(), + )); + }; + if let Some(inner) = &logo { + validate_logo(inner)?; + } + ctx.post( + &format!("/projects/{}/logo", slug), + &serde_json::json!({ "logo": logo }), + ) + .await +} + #[derive(Deserialize, Serialize)] struct PublishResourceTypeBody { name: String, diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 394b031026..0871833613 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -599,15 +599,6 @@ window.open(computeSecretUrl(secretUrl), '_blank') } }, - // { - // displayName: 'Publish to Hub', - // icon: faGlobe, - // action: () => { - // const url = appToHubUrl(toStatic($app, $staticExporter, $summary, $hubBaseUrlStore)) - // window.open(url.toString(), '_blank') - // } - // }, - { displayName: 'App inputs', icon: FormInput, diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 507986294f..4891ac8fc3 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -9,7 +9,7 @@ import type ShareModal from '$lib/components/ShareModal.svelte' import { ScriptService, type Script } from '$lib/gen' - import { hubBaseUrlStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' @@ -36,7 +36,6 @@ Shield, Trash, History, - Globe2, FileText } from 'lucide-svelte' import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte' @@ -47,7 +46,6 @@ import Popover from '$lib/components/Popover.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' - import { scriptToHubUrl } from '$lib/hub' import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' @@ -406,29 +404,6 @@ copyToClipboard(script.path) } }, - { - displayName: 'Publish to Hub', - icon: Globe2, - action: async () => { - const scriptData = await ScriptService.getScriptByPath({ - workspace: $workspaceStore!, - path: script.path - }) - window.open( - scriptToHubUrl( - scriptData.content, - scriptData.summary, - scriptData.description ?? '', - scriptData.kind, - scriptData.language, - scriptData.schema, - scriptData.lock ?? '', - $hubBaseUrlStore - ).toString(), - '_blank' - ) - } - }, { displayName: script.archived ? 'Unarchive' : 'Archive', icon: Archive, diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index 29620b3739..ca9e0ef091 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -10,22 +10,17 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' import { discardDraftAfterDeploy } from '$lib/userDraftToast' - import { rawAppToHubUrl } from '$lib/hub' import { enterpriseLicense, - hubBaseUrlStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores' - import YAML from 'yaml' import { Bug, DiffIcon, - Download, EllipsisVertical, FileJson, - Globe, History, PanelLeft, PanelLeftClose, @@ -295,8 +290,6 @@ let saveDrawerOpen = $state(false) let historyBrowserDrawerOpen = $state(false) - let publishToHubDrawerOpen = $state(false) - let publishingToHub = $state(false) let deploymentMsg: string | undefined = $state(undefined) // Top-bar responsive collapse — container width, not viewport. @@ -307,37 +300,6 @@ // smallest well-supported unified size (`sm`) so the bar is thinner. const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md') - async function publishToHub() { - if (!app) return - publishingToHub = true - try { - const { default: JSZip } = await import('jszip') - const { js, css } = await getBundle() - const zip = new JSZip() - zip.file('app.yaml', YAML.stringify(app)) - zip.file('bundle.js', js) - zip.file('bundle.css', css) - const blob = await zip.generateAsync({ type: 'blob' }) - - // Download the zip - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `${(appPath || 'raw-app').replaceAll('/', '__')}.zip` - a.click() - setTimeout(() => URL.revokeObjectURL(url), 100) - - // Open hub page - const hubUrl = rawAppToHubUrl( - $hubBaseUrlStore, - summary || appPath.split('/').pop()?.replace('_', ' ') || 'my raw app' - ) - window.open(hubUrl.toString(), '_blank') - } finally { - publishingToHub = false - } - } - function closeSaveDrawer() { saveDrawerOpen = false } @@ -615,13 +577,6 @@ displayName: 'Edit in YAML', icon: FileJson, action: () => onOpenYamlEditor?.() - }, - { - displayName: 'Publish to Hub', - icon: Globe, - action: () => { - publishToHubDrawerOpen = true - } } ]) @@ -744,42 +699,6 @@ - - (publishToHubDrawerOpen = false)}> - {#snippet actions()} - - {/snippet} -
-

- This will download a zip file containing your raw app bundle and open the Windmill Hub - submission page. -

-
-

The zip file will contain:

-
    -
  • app.yaml - App configuration
  • -
  • bundle.js - JavaScript bundle
  • -
  • bundle.css - CSS styles
  • -
-
-
-
-
- { diff --git a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte index 97ce779cea..573e8138d9 100644 --- a/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte +++ b/frontend/src/lib/components/workspaceSettings/DeployToHub.svelte @@ -31,6 +31,7 @@ Eye, ExternalLink, Globe, + Image as ImageIcon, Info, LayoutDashboard, Loader2, @@ -99,6 +100,56 @@ async function confirmPublish() { if (await deployHub.session?.confirmPublish()) publishDrawer?.closeDrawer() } + // Client-side mirror of the Hub's logo constraints (it re-validates server-side). + const MAX_LOGO_BYTES = 512 * 1024 + let logoDragOver = $state(false) + let logoFileInput = $state() + async function handleLogoFile(file: File | undefined) { + const s = deployHub.session + if (!s || !file) return + // Browser-reported type wins over the extension: a PNG misnamed *.svg + // must be treated as PNG or the Hub's content sniff rejects it later. + const lower = file.name.toLowerCase() + const mime = + file.type + ? file.type === 'image/png' + ? 'image/png' + : file.type === 'image/svg+xml' + ? 'image/svg+xml' + : undefined + : lower.endsWith('.png') + ? 'image/png' + : lower.endsWith('.svg') + ? 'image/svg+xml' + : undefined + if (!mime) { + sendUserToast('Logo must be a PNG or SVG file', true) + return + } + if (file.size > MAX_LOGO_BYTES) { + sendUserToast('Logo too large (max 512KB)', true) + return + } + const buf = new Uint8Array(await file.arrayBuffer()) + let bin = '' + for (let i = 0; i < buf.length; i += 0x8000) { + bin += String.fromCharCode(...buf.subarray(i, i + 0x8000)) + } + s.hubLogo = { b64: btoa(bin), mime, name: file.name } + } + async function onLogoPicked(e: Event) { + const input = e.currentTarget as HTMLInputElement + const file = input.files?.[0] + // Reset so re-picking the same file re-fires `change`. + input.value = '' + await handleLogoFile(file) + } + async function onLogoDrop(e: DragEvent) { + e.preventDefault() + logoDragOver = false + await handleLogoFile(e.dataTransfer?.files?.[0]) + } + async function copyIframeSnippet(url: string) { const snippet = `` try { @@ -1114,6 +1165,121 @@ inputProps={{ placeholder: 'Short one-liner shown on the Hub card' }} /> +
+ {s.hubLogo ? 'Preview' : 'Logo'} + {#if s.hubLogo} + + + This is how your project card will look on the Hub. + +
+
+
+ Project logo preview +
+
+
+ {s.hubName.trim() || 'Project name'} +
+

+ {s.hubSummary.trim() || s.hubName.trim() || 'Short one-liner shown on the Hub card'} +

+
+
+
+ + +
+
+ {:else} + {#if s.hubLogo === null} +
+ + The project's current logo will be removed when you publish. + + +
+ {:else if s.hubHasRemoteLogo} +
+ + This project already has a custom logo on the Hub. + + +
+ {/if} + + {/if} + + {#if s.hubLogo === undefined} + + Optional. Shown on the Hub project card and page. Leaving it empty keeps the + project's current logo. + + {/if} +