From 71232f749a055690b6560e609ce36f063238f0ae Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 05:02:19 -0700 Subject: [PATCH] studio/frontend: fix onboarding CSP violations (#5658) * studio/frontend: fix onboarding CSP violations Two onboarding-only CSP violations were showing up in the browser console on a default install: * `WizardSidebar` rendered the brand sticker from `https://unsloth.ai/cgi/image/unsloth_sticker_no_shadow_*.png`, which is not in the Studio CSP `img-src` allowlist. The sticker rendered as a broken image. * `Confetti` defaulted `globalOptions.useWorker` to `true`, so `canvas-confetti` tried to spawn an OffscreenCanvas worker from a `blob:` URL. CSP `script-src 'self'` blocks it; three blocked- worker errors fired on the final wizard step. Use the bundled `/sticker.png` for the brand image, and default the Confetti wrapper to the main-thread fallback. CSP stays tight. Resolves #5657. * studio/frontend: harden CSP confetti fix + BASE_URL sticker Address review feedback on #5658: 1. confetti.tsx - Hoist the default globalOptions to a module-scope constant so the prop default has a stable identity across renders (canvasRef's dependency array no longer churns every render). - Always force useWorker:false at the confetti.create site, regardless of what the caller passed in globalOptions. Previously a caller that set `{ resize: true }` would silently re-enable the worker and trip the CSP block again. - Add a lazily-mounted, module-scoped CSP-safe instance and route ConfettiButton through it instead of the global confetti() (which defaults to useWorker:true and would otherwise violate CSP). 2. confetti-fireworks.ts - Replace the direct confetti(...) calls (global instance, default worker on) with calls to a shared confetti.create instance with useWorker:false. The guided-tour completion confetti no longer trips the CSP block. 3. wizard-sidebar.tsx - Use import.meta.env.BASE_URL prefix on the sticker src so the asset still resolves when Studio is deployed under a subpath (e.g. /studio/). Defaults to "/" so single-host installs are unchanged. tsc clean, bun run build clean, bundle confirms the changes (`{resize:!0,useWorker:!1}` appears in every relevant call site). * studio/tour: preserve opts.zIndex on shared confetti fireworks canvas Address chatgpt-codex-connector inline review on #5658 follow-up: When canvas-confetti runs against a caller-provided canvas (which is what we need for the CSP fix), the per-fire `zIndex` option is ignored for stacking purposes -- the canvas element's own CSS `z-index` is what the browser uses. The previous follow-up hard-coded the shared canvas to `z-index:99999`, so callers that pass `opts.zIndex` (or expect the old global-confetti behavior of being able to lower fireworks under an overlay) silently lost that knob. Apply `opts.zIndex` to the shared canvas's `style.zIndex` on each call (default 99999 still used when omitted). Same default; behavior is now restored for the lower/raise case. The current only caller (`guided-tour.tsx` invoking `fireConfettiFireworks()` with no args) is unaffected since it never provided `opts.zIndex`. Public API contract is preserved. * studio/frontend: drop dead ConfettiButton + BASE_URL onboarding mascots - confetti.tsx: remove unused ConfettiButton + getSharedConfettiFire singleton (0 callsites) - splash-screen.tsx, wizard-content.tsx: prefix sloth mascot paths with import.meta.env.BASE_URL so onboarding works under non-root subpaths - confetti-fireworks.ts: drop dead per-fire zIndex from defaults (caller-provided canvas ignores it; we already drive stacking via canvas style) * studio/frontend: BASE_URL on HF icon + race-safe shared fireworks init - dataset-step.tsx: prefix the Hugging Face dataset-source icon with import.meta.env.BASE_URL so it resolves correctly under non-root deployments. Last onboarding asset that was still root-relative after the earlier BASE_URL sweep. - confetti-fireworks.ts: cache the in-flight init promise in getSharedFire so two same-tick callers share the dynamic import and the appended overlay canvas. Previously two concurrent fireConfettiFireworks() calls each appended a fixed full-screen canvas and orphaned the first one. * studio/frontend: tighten confetti CSP comments --------- Co-authored-by: danielhanchen --- .../frontend/src/components/ui/confetti.tsx | 262 ++++++++---------- .../onboarding/components/splash-screen.tsx | 2 +- .../components/steps/dataset-step.tsx | 2 +- .../onboarding/components/wizard-content.tsx | 10 +- .../onboarding/components/wizard-sidebar.tsx | 2 +- .../features/tour/lib/confetti-fireworks.ts | 47 +++- 6 files changed, 166 insertions(+), 159 deletions(-) diff --git a/studio/frontend/src/components/ui/confetti.tsx b/studio/frontend/src/components/ui/confetti.tsx index acd618d218..892bffdb18 100644 --- a/studio/frontend/src/components/ui/confetti.tsx +++ b/studio/frontend/src/components/ui/confetti.tsx @@ -1,150 +1,118 @@ // SPDX-License-Identifier: AGPL-3.0-only // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -import type { - GlobalOptions as ConfettiGlobalOptions, - CreateTypes as ConfettiInstance, - Options as ConfettiOptions, -} from "canvas-confetti"; -import confetti from "canvas-confetti"; -import type { ReactNode } from "react"; -import type React from "react"; -import { - createContext, - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, -} from "react"; - -import { Button } from "@/components/ui/button"; - -type Api = { - fire: (options?: ConfettiOptions) => void; -}; - -type Props = React.ComponentPropsWithRef<"canvas"> & { - options?: ConfettiOptions; - globalOptions?: ConfettiGlobalOptions; - manualstart?: boolean; - children?: ReactNode; -}; - -export type ConfettiRef = Api | null; - -const ConfettiContext = createContext({} as Api); - -// Define component first -const ConfettiComponent = forwardRef((props, ref) => { - const { - options, - globalOptions = { resize: true, useWorker: true }, - manualstart = false, - children, - ...rest - } = props; - const instanceRef = useRef(null); - - const canvasRef = useCallback( - (node: HTMLCanvasElement) => { - if (node !== null) { - if (instanceRef.current) return; - instanceRef.current = confetti.create(node, { - ...globalOptions, - resize: true, - }); - } else { - if (instanceRef.current) { - instanceRef.current.reset(); - instanceRef.current = null; - } - } - }, - [globalOptions], - ); - - const fire = useCallback( - async (opts = {}) => { - try { - await instanceRef.current?.({ ...options, ...opts }); - } catch (error) { - console.error("Confetti error:", error); - } - }, - [options], - ); - - const api = useMemo( - () => ({ - fire, - }), - [fire], - ); - - useImperativeHandle(ref, () => api, [api]); - - useEffect(() => { - if (!manualstart) { - (async () => { - try { - await fire(); - } catch (error) { - console.error("Confetti effect error:", error); - } - })(); - } - }, [manualstart, fire]); - - return ( - - - {children} - - ); -}); - -// Set display name immediately -ConfettiComponent.displayName = "Confetti"; - -// Export as Confetti -export const Confetti = ConfettiComponent; - -interface ConfettiButtonProps extends React.ComponentProps<"button"> { - options?: ConfettiOptions & - ConfettiGlobalOptions & { canvas?: HTMLCanvasElement }; -} - -const ConfettiButtonComponent = ({ - options, - children, - ...props -}: ConfettiButtonProps) => { - const handleClick = async (event: React.MouseEvent) => { - try { - const rect = event.currentTarget.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; - await confetti({ - ...options, - origin: { - x: x / window.innerWidth, - y: y / window.innerHeight, - }, - }); - } catch (error) { - console.error("Confetti button error:", error); - } - }; - - return ( - - ); -}; - -ConfettiButtonComponent.displayName = "ConfettiButton"; - -export const ConfettiButton = ConfettiButtonComponent; +import type { + GlobalOptions as ConfettiGlobalOptions, + CreateTypes as ConfettiInstance, + Options as ConfettiOptions, +} from "canvas-confetti"; +import confetti from "canvas-confetti"; +import type { ReactNode } from "react"; +import type React from "react"; +import { + createContext, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, +} from "react"; + +type Api = { + fire: (options?: ConfettiOptions) => void; +}; + +type Props = React.ComponentPropsWithRef<"canvas"> & { + options?: ConfettiOptions; + globalOptions?: ConfettiGlobalOptions; + manualstart?: boolean; + children?: ReactNode; +}; + +export type ConfettiRef = Api | null; + +const ConfettiContext = createContext({} as Api); + +// Studio CSP blocks canvas-confetti's default blob: worker, so force +// useWorker: false. Module-scoped so the prop default keeps stable +// identity across renders (`canvasRef` depends on `globalOptions`). +const DEFAULT_GLOBAL_OPTIONS: ConfettiGlobalOptions = { + resize: true, + useWorker: false, +}; + +const ConfettiComponent = forwardRef((props, ref) => { + const { + options, + globalOptions = DEFAULT_GLOBAL_OPTIONS, + manualstart = false, + children, + ...rest + } = props; + const instanceRef = useRef(null); + + const canvasRef = useCallback( + (node: HTMLCanvasElement) => { + if (node !== null) { + if (instanceRef.current) return; + instanceRef.current = confetti.create(node, { + ...globalOptions, + resize: true, + // Force off after the spread so caller globalOptions can't + // re-enable the worker and trip CSP. + useWorker: false, + }); + } else { + if (instanceRef.current) { + instanceRef.current.reset(); + instanceRef.current = null; + } + } + }, + [globalOptions], + ); + + const fire = useCallback( + async (opts = {}) => { + try { + await instanceRef.current?.({ ...options, ...opts }); + } catch (error) { + console.error("Confetti error:", error); + } + }, + [options], + ); + + const api = useMemo( + () => ({ + fire, + }), + [fire], + ); + + useImperativeHandle(ref, () => api, [api]); + + useEffect(() => { + if (!manualstart) { + (async () => { + try { + await fire(); + } catch (error) { + console.error("Confetti effect error:", error); + } + })(); + } + }, [manualstart, fire]); + + return ( + + + {children} + + ); +}); + +ConfettiComponent.displayName = "Confetti"; + +export const Confetti = ConfettiComponent; diff --git a/studio/frontend/src/features/onboarding/components/splash-screen.tsx b/studio/frontend/src/features/onboarding/components/splash-screen.tsx index ce828ee7b4..70438a04d8 100644 --- a/studio/frontend/src/features/onboarding/components/splash-screen.tsx +++ b/studio/frontend/src/features/onboarding/components/splash-screen.tsx @@ -20,7 +20,7 @@ export function SplashScreen({ {/* Mascot */}
= { - 1: "/Sloth emojis/large sloth wave.png", - 2: "/Sloth emojis/sloth magnify final.png", - 3: "/Sloth emojis/sloth huglove large.png", - 4: "/Sloth emojis/large sloth glasses.png", - 5: "/Sloth emojis/large sloth yay.png", + 1: `${import.meta.env.BASE_URL}Sloth emojis/large sloth wave.png`, + 2: `${import.meta.env.BASE_URL}Sloth emojis/sloth magnify final.png`, + 3: `${import.meta.env.BASE_URL}Sloth emojis/sloth huglove large.png`, + 4: `${import.meta.env.BASE_URL}Sloth emojis/large sloth glasses.png`, + 5: `${import.meta.env.BASE_URL}Sloth emojis/large sloth yay.png`, }; export function WizardContent() { diff --git a/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx b/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx index d22ac19e51..4637336c6c 100644 --- a/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx +++ b/studio/frontend/src/features/onboarding/components/wizard-sidebar.tsx @@ -18,7 +18,7 @@ export function WizardSidebar({ returnTo }: { returnTo: string }) {