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 <michaelhan2050@gmail.com>
This commit is contained in:
parent
4c26def4b3
commit
71232f749a
6 changed files with 166 additions and 159 deletions
|
|
@ -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<Api>({} as Api);
|
||||
|
||||
// Define component first
|
||||
const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
|
||||
const {
|
||||
options,
|
||||
globalOptions = { resize: true, useWorker: true },
|
||||
manualstart = false,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const instanceRef = useRef<ConfettiInstance | null>(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 (
|
||||
<ConfettiContext.Provider value={api}>
|
||||
<canvas ref={canvasRef} {...rest} />
|
||||
{children}
|
||||
</ConfettiContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
// 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<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<Button onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
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<Api>({} 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<ConfettiRef, Props>((props, ref) => {
|
||||
const {
|
||||
options,
|
||||
globalOptions = DEFAULT_GLOBAL_OPTIONS,
|
||||
manualstart = false,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const instanceRef = useRef<ConfettiInstance | null>(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 (
|
||||
<ConfettiContext.Provider value={api}>
|
||||
<canvas ref={canvasRef} {...rest} />
|
||||
{children}
|
||||
</ConfettiContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
ConfettiComponent.displayName = "Confetti";
|
||||
|
||||
export const Confetti = ConfettiComponent;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function SplashScreen({
|
|||
{/* Mascot */}
|
||||
<div className="flex justify-center">
|
||||
<motion.img
|
||||
src="/Sloth emojis/Sloth loca pc.png"
|
||||
src={`${import.meta.env.BASE_URL}Sloth emojis/Sloth loca pc.png`}
|
||||
alt="Sloth mascot"
|
||||
className="size-30"
|
||||
initial={{ opacity: 0, y: 40, scale: 0.95 }}
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export function DatasetStep() {
|
|||
className="flex-1"
|
||||
>
|
||||
<img
|
||||
src="/huggingface.svg"
|
||||
src={`${import.meta.env.BASE_URL}huggingface.svg`}
|
||||
alt=""
|
||||
className="size-4 invert"
|
||||
data-icon="inline-start"
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ const STEP_COMPONENTS = {
|
|||
} as const;
|
||||
|
||||
const STEP_MASCOTS: Record<StepNumber, string> = {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function WizardSidebar({ returnTo }: { returnTo: string }) {
|
|||
<aside className="w-full shrink-0 bg-muted/70 p-4 md:w-64 md:p-6">
|
||||
<div className="flex items-center gap-3 py-1 md:py-2">
|
||||
<img
|
||||
src="https://unsloth.ai/cgi/image/unsloth_sticker_no_shadow_ldN4V4iydw00qSIIWDCUv.png?width=96&quality=80&format=auto"
|
||||
src={`${import.meta.env.BASE_URL}sticker.png`}
|
||||
alt="Unsloth"
|
||||
className="size-12"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,48 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import type { CreateTypes as ConfettiInstance } from "canvas-confetti";
|
||||
|
||||
type FireworksOpts = {
|
||||
durationMs?: number;
|
||||
intervalMs?: number;
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
// CSP blocks canvas-confetti's default blob: worker, so reuse a single
|
||||
// overlay canvas via `confetti.create(..., { useWorker: false })`.
|
||||
// Caller-provided canvases ignore the per-fire `zIndex`; stacking is
|
||||
// driven by `_sharedCanvas.style.zIndex` instead (set in fireConfettiFireworks).
|
||||
const DEFAULT_FIREWORKS_Z_INDEX = 99999;
|
||||
let _sharedCanvas: HTMLCanvasElement | null = null;
|
||||
let _sharedFire: ConfettiInstance | null = null;
|
||||
// Cache the init promise so concurrent callers share one import + canvas.
|
||||
let _sharedFirePromise: Promise<ConfettiInstance | null> | null = null;
|
||||
function getSharedFire(): Promise<ConfettiInstance | null> {
|
||||
if (typeof document === "undefined") return Promise.resolve(null);
|
||||
if (_sharedFire) return Promise.resolve(_sharedFire);
|
||||
if (_sharedFirePromise) return _sharedFirePromise;
|
||||
_sharedFirePromise = (async () => {
|
||||
try {
|
||||
const confetti = (await import("canvas-confetti")).default;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.cssText =
|
||||
`position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:${DEFAULT_FIREWORKS_Z_INDEX}`;
|
||||
document.body.appendChild(canvas);
|
||||
_sharedCanvas = canvas;
|
||||
_sharedFire = confetti.create(canvas, {
|
||||
resize: true,
|
||||
useWorker: false,
|
||||
});
|
||||
return _sharedFire;
|
||||
} catch (err) {
|
||||
_sharedFirePromise = null;
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
return _sharedFirePromise;
|
||||
}
|
||||
|
||||
export async function fireConfettiFireworks(opts: FireworksOpts = {}) {
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
|
|
@ -17,7 +53,11 @@ export async function fireConfettiFireworks(opts: FireworksOpts = {}) {
|
|||
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
|
||||
if (prefersReduce) return;
|
||||
|
||||
const confetti = (await import("canvas-confetti")).default;
|
||||
const fire = await getSharedFire();
|
||||
if (!fire || !_sharedCanvas) return;
|
||||
|
||||
// Per-fire zIndex is ignored on a shared canvas; drive stacking via CSS.
|
||||
_sharedCanvas.style.zIndex = String(opts.zIndex ?? DEFAULT_FIREWORKS_Z_INDEX);
|
||||
|
||||
const duration = opts.durationMs ?? 1200;
|
||||
const intervalMs = opts.intervalMs ?? 240;
|
||||
|
|
@ -26,7 +66,6 @@ export async function fireConfettiFireworks(opts: FireworksOpts = {}) {
|
|||
startVelocity: 28,
|
||||
spread: 360,
|
||||
ticks: 58,
|
||||
zIndex: opts.zIndex ?? 99999,
|
||||
disableForReducedMotion: true,
|
||||
} as const;
|
||||
|
||||
|
|
@ -45,12 +84,12 @@ export async function fireConfettiFireworks(opts: FireworksOpts = {}) {
|
|||
Math.floor(36 * (timeLeft / duration)),
|
||||
);
|
||||
|
||||
confetti({
|
||||
fire({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.12, 0.3), y: Math.random() - 0.2 },
|
||||
});
|
||||
confetti({
|
||||
fire({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.7, 0.88), y: Math.random() - 0.2 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue