diff --git a/backend/windmill-api/src/hub_publish.rs b/backend/windmill-api/src/hub_publish.rs index 2aee6bc360..93445a2350 100644 --- a/backend/windmill-api/src/hub_publish.rs +++ b/backend/windmill-api/src/hub_publish.rs @@ -6,13 +6,14 @@ use axum::{ http::{request::Parts, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, - Router, + Extension, Router, }; use serde::{Deserialize, Deserializer, Serialize}; use windmill_common::{ error::{to_anyhow, Error}, + global_settings::{load_value_from_global_settings, DISABLE_HUB_SETTING}, utils::require_admin, - HUB_BASE_URL, + DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, }; pub fn workspaced_service() -> Router { @@ -553,17 +554,67 @@ async fn get_project_by_source(ctx: HubPublishCtx) -> Result bool { + fn host_of(url: &str) -> Option { + let parsed = url::Url::parse(url.trim()).ok()?; + if !matches!(parsed.scheme(), "http" | "https") { + return None; + } + Some( + parsed + .host_str()? + .trim_end_matches('.') + .to_ascii_lowercase(), + ) + } + match (host_of(hub), host_of(DEFAULT_HUB_BASE_URL)) { + (Some(host), Some(default_host)) => host == default_host, + _ => false, + } +} + async fn list_projects( _authed: ApiAuthed, + Extension(db): Extension, Tokened { token }: Tokened, ) -> Result { - let url = format!("{}/projects", **HUB_BASE_URL.load()); - let res = HTTP_CLIENT - .get(&url) - .header("accept", "application/json") - .bearer_auth(&token) + // `disable_hub` turns the hub off for a closed instance, and this handler makes an + // outbound request. The frontend hides its entry points on the same setting, but that + // is presentation: an authenticated member can call this route directly, so the refusal + // has to live here. + let disabled = load_value_from_global_settings(&db, DISABLE_HUB_SETTING) + .await? + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if disabled { + return Err(Error::BadRequest( + "The hub is disabled on this instance".to_string(), + )); + } + + let hub = (**HUB_BASE_URL.load()).clone(); + let url = format!("{}/projects", hub); + let mut req = HTTP_CLIENT.get(&url).header("accept", "application/json"); + if !is_public_hub(&hub) { + req = req.bearer_auth(&token); + } + let res = req .send() .await .map_err(|e| Error::InternalErr(format!("hub request failed: {e}")))?; @@ -674,3 +725,42 @@ async fn forward_to_hub( Ok((status, text)) } + +#[cfg(test)] +mod tests { + use super::is_public_hub; + + #[test] + fn public_hub_recognized_in_every_spelling() { + // The predicate decides whether a workspace member's bearer token leaves the + // instance, so both directions matter: a miss on the public hub sends the token + // to windmill.dev, and a false match withholds it from a private hub that needs it. + // Every spelling here is one `hub_base_url` can hold and `reqwest` will still send. + for hub in [ + "https://hub.windmill.dev", + "http://hub.windmill.dev/", + "HTTPS://hub.windmill.dev", + "https://HUB.WINDMILL.DEV", + "https://hub.windmill.dev:443", + "https://hub.windmill.dev.", + "https://hub.windmill.dev/some/path", + " https://hub.windmill.dev ", + ] { + assert!(is_public_hub(hub), "{hub} should be the public hub"); + } + for hub in [ + "https://hub.internal.example", + "https://hub.windmill.dev.evil.example", + "https://windmill.dev", + // The host is what the request goes to, whatever precedes the `@`. + "https://hub.windmill.dev@hub.internal.example", + // Unparseable, or not a scheme a request can be built from. Grouped with the + // private hubs because the caller then attaches the token, which is harmless here: + // `reqwest` rejects the same value before opening a connection. + "hub.windmill.dev", + "ftp://hub.windmill.dev", + ] { + assert!(!is_public_hub(hub), "{hub} should not be the public hub"); + } + } +} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index c3ea5fbe9a..3a1abfce91 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -64,15 +64,7 @@ import { setNoteEditorContext } from './graph/noteEditor.svelte' import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte' import { cleanFlow } from './flows/utils.svelte' - import { - DiffIcon, - HistoryIcon, - FileJson, - Settings, - Undo, - Redo, - Disc - } from 'lucide-svelte' + import { DiffIcon, HistoryIcon, FileJson, Settings, Undo, Redo, Disc } from 'lucide-svelte' import Awareness from './Awareness.svelte' import { getAllModules } from './flows/flowExplorer' import { type FlowCopilotContext } from './copilot/flow' diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 73c6b4884e..39efa2d090 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -1061,8 +1061,7 @@
  • worker usage (worker, worker instance, vCPUs, memory)
  • user usage (author count, operator count, the distinct guests of the last 30 days, - the seats they add past the free allowance, and the workspaces that allow - guests)
  • superadmin email addresses
  • development instance status
  • @@ -1078,8 +1077,8 @@ loaded, whether SSO logins evaluate an IdP groups claim (SAML or OIDC) and change a membership, the plan tier and quota shown when the execution meter is opened, whether app sandbox isolation is turned on, whether a step's workspace script is edited from - the flow editor, how data tables and their migrations are set up and used, how often an - empty workspace home is seen, how often the home page’s create menu and hub-project + the flow editor, how data tables and their migrations are set up and used, how often + an empty workspace home is seen, how often the home page’s create menu and hub-project picker are opened and from which entry point, and the name of any public hub project imported from the home page and how far that import got, last 30 days) @@ -1131,8 +1130,7 @@
  • worker usage (worker, worker instance, vCPUs, memory)
  • user usage (author count, operator count, the distinct guests of the last 30 days, - the seats they add past the free allowance, and the workspaces that allow - guests)
  • development instance status
  • diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 21d029178e..ab043f81a1 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -7,12 +7,7 @@ import { redo, undo } from '$lib/history.svelte' import { discardDraftAfterDeploy } from '$lib/userDraftToast' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' - import { - enterpriseLicense, - userStore, - userWorkspaces, - workspaceStore - } from '$lib/stores' + import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { isMac, type Item, userPathPrefix } from '$lib/utils' import { random_adj } from '$lib/components/random_positive_adjetive' import { diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte index 622e275ae5..a4bed4affb 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte @@ -121,10 +121,7 @@ -
    +
    Choose a language
    diff --git a/frontend/src/lib/components/home/ImportProjectModal.svelte b/frontend/src/lib/components/home/ImportProjectModal.svelte index abd034a2a6..60cf3c55a8 100644 --- a/frontend/src/lib/components/home/ImportProjectModal.svelte +++ b/frontend/src/lib/components/home/ImportProjectModal.svelte @@ -18,6 +18,13 @@ import { logFeatureUsage } from '$lib/utils/featureUsage' import { sendUserToast } from '$lib/toast' + /** + * Writes a hub project's items into the active workspace. It checks no permission itself, + * so a caller must not offer it to an operator or in a workspace whose direct-deploy + * protection has cleared `showEditButtons` — `ItemsList` gates both of its entry points on + * exactly that, and the import would otherwise fail item by item against the server's own + * checks, after the dialog had promised to run. + */ interface Props { /** The project the picker chose. Setting it opens the dialog. */ pick: HubProjectPick | undefined @@ -133,9 +140,18 @@ // the import step show them, so the detail is fetched for the one project chosen. The // card renders from the pick until it lands — counts are the only thing missing, and // zero counts render as no badges rather than as zeroes. + // + // The answer is checked against the slug that asked for it: `resource()` aborts the + // previous controller but `fetchHubProject` takes no signal, and nothing orders the + // responses — so picking A, dismissing, then picking B can land A's name, author and + // counts over an import that writes B. const detail = resource( () => slug, - async (s) => (s ? await fetchHubProject(s) : undefined) + async (s) => { + if (!s) return undefined + const fetched = await fetchHubProject(s) + return s === slug ? fetched : detail.current + } ) let project = $derived( detail.current ?? @@ -178,13 +194,7 @@ // The counters' key vocabularies, enumerated here so the whole set is reviewable at once. type AbandonStage = 'running' | 'setup' | 'done' | 'idle' type SetupOutcome = 'filled' | 'skipped' | 'none' | 'unchecked' - type SetupBucket = - | 'filled' - | 'none' - | 'unchecked' - | 'skipped_1' - | 'skipped_2_5' - | 'skipped_6plus' + type SetupBucket = 'filled' | 'none' | 'unchecked' | 'skipped_1' | 'skipped_2_5' | 'skipped_6plus' // Set for the closing that Finish itself asks for, since that closing reaches `dismiss()` // by the same falling edge as the X. diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 25a30b2bd8..32ff27dd64 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -1016,10 +1016,12 @@ * Whether a workspace the default listing found empty is empty at all, or just has nothing * unarchived — two different states that want two different things said about them. Asked * only in that case, and once per workspace: one request for one row, never on a workspace - * with something in it. A failure answers "no archived items", which shows the ordinary - * placeholder rather than promising items that may not be there. + * with something in it. `hasArchived` is undefined when the request failed — see the catch + * for what that leaves standing. */ - let archivedProbe = $state<{ workspace: string; hasArchived: boolean } | undefined>(undefined) + let archivedProbe = $state<{ workspace: string; hasArchived: boolean | undefined } | undefined>( + undefined + ) $effect(() => { const ws = $workspaceStore if (!ws || !workspaceEmpty || archivedProbe?.workspace === ws) return @@ -1039,11 +1041,20 @@ }) archivedProbe = { workspace, hasArchived: (res.items?.length ?? 0) > 0 } } catch (error) { + // Undefined, not false: false would say the workspace is empty and — since the + // toolbar is inert on the strength of the placeholder carrying the way to archived + // items — leave no way to them at all. Unknown keeps the ordinary caption, which + // promises nothing, and leaves the searchbar live as the fallback it used to be. console.error('Could not check for archived items:', error) - archivedProbe = { workspace, hasArchived: false } + archivedProbe = { workspace, hasArchived: undefined } } } let emptyStateAnswered = $derived(archivedProbe?.workspace === $workspaceStore) + /** + * The probe could not tell. The toolbar stays usable in that case: `inert` is only right + * while the placeholder is the way to archived items, and here it cannot be. + */ + let archivedUnknown = $derived(emptyStateAnswered && archivedProbe?.hasArchived === undefined) /** * Whether this user may be offered the create actions. The empty state's template import * and create menu do no permission check of their own, so an operator — or a workspace @@ -1066,6 +1077,12 @@ visiblePipelineFolders.size === 0 && !hasMoreServer ) + /** + * The toolbar is dimmed either way; `inert` also takes it off the pointer, which is only + * right while the placeholder carries the way to archived items. A probe that could not + * tell leaves it live as the fallback. + */ + let toolbarInert = $derived(workspaceEmpty && !archivedUnknown) // Owners the counts found the user has something in, split by kind. They cover // what the folder/username lists miss: an item shared individually out of a @@ -1736,7 +1753,7 @@ lands; `inert` takes it out of the tab order and off the pointer meanwhile. A workspace with nothing but archived items reaches them from its own placeholder, so these controls are not the way there. --> -
    +
    { @@ -1838,7 +1855,7 @@