fix(routing): guard remote stack hydration and settle empty env routes (#1602)

Gate stack resolution until the active node matches a pending remote deep link.
Return env inventory from loadFileForRoute so empty stacks settle env tab routes.
Reject backslashes in buildPath env query tokens.
This commit is contained in:
Anso
2026-07-08 23:09:05 -04:00
committed by GitHub
parent beb1f9ef07
commit 7517a4f49c
8 changed files with 198 additions and 48 deletions
@@ -10,7 +10,7 @@ import type { Node } from '@/context/NodeContext';
import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
import { parsePath } from '@/lib/router/senchoRoute';
import { resolveEnvFilePath } from '@/lib/router/envRoute';
import type { EditorTab } from '@/lib/router/routeTypes';
import type { EditorTab, RouteStackLoadResult } from '@/lib/router/routeTypes';
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
import type { NotificationItem } from '../../dashboard/types';
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
@@ -423,16 +423,16 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setEnvEtag(null);
};
const loadEnvState = async (filename: string, signal?: AbortSignal) => {
const loadEnvState = async (filename: string, signal?: AbortSignal): Promise<string[]> => {
try {
const envsRes = await apiFetch(`/stacks/${filename}/envs`, { signal });
if (signal?.aborted) return;
if (signal?.aborted) return [];
if (!envsRes.ok) {
clearEnvState();
return;
return [];
}
const { envFiles } = await envsRes.json();
if (signal?.aborted) return;
if (signal?.aborted) return [];
if (envFiles && envFiles.length > 0) {
editorState.setEnvFiles(envFiles);
const firstFile = envFiles[0];
@@ -442,7 +442,7 @@ export function useStackActions(options: UseStackActionsOptions) {
`/stacks/${filename}/env?file=${encodeURIComponent(firstFile)}`,
{ signal },
);
if (signal?.aborted) return;
if (signal?.aborted) return envFiles;
if (envContentRes.ok) {
const envText = await envContentRes.text();
editorState.setEnvContent(envText || '');
@@ -453,12 +453,14 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setOriginalEnvContent('');
editorState.setEnvEtag(null);
}
} else {
clearEnvState();
return envFiles;
}
} catch (err) {
if (isAbortError(err)) return;
clearEnvState();
return [];
} catch (err) {
if (isAbortError(err)) return [];
clearEnvState();
return [];
}
};
@@ -493,15 +495,15 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setIsEditing(false);
};
const loadFileCore = async (filename: string): Promise<boolean> => {
if (!filename) return false;
const loadFileCore = async (filename: string): Promise<RouteStackLoadResult> => {
if (!filename) return { ok: false };
if (
stackListState.selectedFile &&
filename !== stackListState.selectedFile &&
hasUnsavedChanges()
) {
overlayState.setPendingUnsavedLoad(filename);
return false;
return { ok: false };
}
loadFileAbortRef.current?.abort();
const controller = new AbortController();
@@ -514,9 +516,9 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setActiveTab('compose');
try {
const res = await apiFetch(`/stacks/${filename}`, { signal });
if (signal.aborted) return false;
if (signal.aborted) return { ok: false };
const text = await res.text();
if (signal.aborted) return false;
if (signal.aborted) return { ok: false };
if (!res.ok) {
throw new Error(`Failed to load stack: ${res.status}`);
}
@@ -525,12 +527,12 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setContent(text || '');
editorState.setOriginalContent(text || '');
editorState.setComposeEtag(res.headers.get('etag'));
await loadEnvState(filename, signal);
const envFiles = await loadEnvState(filename, signal);
await loadContainerState(filename, signal);
await loadBackupState(filename, signal);
return true;
return { ok: true, envFiles };
} catch (error) {
if (isAbortError(error) || signal.aborted) return false;
if (isAbortError(error) || signal.aborted) return { ok: false };
console.error('Failed to load file:', error);
toast.error(`Could not open "${filename.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`);
stackListState.setSelectedFile(null);
@@ -541,7 +543,7 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setOriginalEnvContent('');
editorState.setEnvEtag(null);
editorState.setContainers([]);
return false;
return { ok: false };
} finally {
if (!signal.aborted) {
editorState.setIsFileLoading(false);
@@ -553,7 +555,7 @@ export function useStackActions(options: UseStackActionsOptions) {
await loadFileCore(filename);
};
const loadFileForRoute = async (filename: string): Promise<boolean> => {
const loadFileForRoute = async (filename: string): Promise<RouteStackLoadResult> => {
return loadFileCore(filename);
};
@@ -57,7 +57,7 @@ function makeOpts(over: Partial<UseUrlSyncOptions> = {}): UseUrlSyncOptions {
setActiveTab: vi.fn(),
selectedEnvFile: '',
envFiles: [],
loadFileForRoute: vi.fn().mockResolvedValue(true),
loadFileForRoute: vi.fn().mockResolvedValue({ ok: true, envFiles: [] }),
changeEnvFile: vi.fn().mockResolvedValue(undefined),
applyEditorRouteState: vi.fn(),
refreshStacks: vi.fn().mockResolvedValue(['radarr']),
@@ -127,10 +127,94 @@ describe('useUrlSync', () => {
pushSpy.mockRestore();
});
it('does not resolve remote stack against stale local node files', async () => {
const remote = makeNode({ id: 2, name: 'nas', type: 'remote', is_default: false });
const local = makeNode();
const setActiveNode = vi.fn();
const setActiveView = vi.fn();
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: [] });
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/nas-2/stacks/remote-stack/compose');
const { rerender } = renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
nodes: [local, remote],
activeNode: local,
activeView: 'dashboard',
files: ['local-only'],
filesNodeId: 1,
stacksLoadStatus: 'success',
stacksLoadNodeId: 1,
setActiveNode,
setActiveView,
loadFileForRoute,
}),
},
);
await act(async () => {
await Promise.resolve();
});
expect(setActiveNode).toHaveBeenCalledWith(remote);
expect(setActiveView).not.toHaveBeenCalledWith('dashboard');
expect(loadFileForRoute).not.toHaveBeenCalled();
rerender(makeOpts({
nodes: [local, remote],
activeNode: remote,
activeView: 'dashboard',
files: ['remote-stack'],
filesNodeId: 2,
stacksLoadStatus: 'success',
stacksLoadNodeId: 2,
setActiveNode,
setActiveView,
loadFileForRoute,
}));
await act(async () => {
await Promise.resolve();
});
expect(loadFileForRoute).toHaveBeenCalledWith('remote-stack');
expect(setActiveView).not.toHaveBeenCalledWith('dashboard');
});
it('settles env tab route when stack has no env files', async () => {
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: [] });
const applyEditorRouteState = vi.fn();
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/env');
renderHook(
(props) => useUrlSync(props),
{
initialProps: makeOpts({
activeView: 'editor',
files: ['radarr'],
selectedFile: null,
envFiles: [],
loadFileForRoute,
applyEditorRouteState,
}),
},
);
await act(async () => {
await Promise.resolve();
});
expect(loadFileForRoute).toHaveBeenCalledWith('radarr');
expect(applyEditorRouteState).toHaveBeenCalledWith('compose');
});
it('restores non-default env selection after stack load populates file list', async () => {
const prodPath = '/compose/radarr/.env.prod';
const fileList = ['/compose/radarr/.env', prodPath];
const loadFileForRoute = vi.fn().mockResolvedValue(true);
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: true, envFiles: fileList });
const changeEnvFile = vi.fn().mockResolvedValue(undefined);
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/env?env=.env.prod');
@@ -351,7 +435,7 @@ describe('useUrlSync', () => {
it('freezes route and sets routeDetailError when compose load fails', async () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/compose');
const loadFileForRoute = vi.fn().mockResolvedValue(false);
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: false });
const setPendingDetailStack = vi.fn();
const { result } = renderHook(
@@ -377,7 +461,7 @@ describe('useUrlSync', () => {
it('clears routeDetailError after a successful retry', async () => {
window.history.replaceState({ senchoIdx: 0 }, '', '/nodes/local/stacks/radarr/compose');
const loadFileForRoute = vi.fn().mockResolvedValue(false);
const loadFileForRoute = vi.fn().mockResolvedValue({ ok: false });
const { result, rerender } = renderHook(
(props) => useUrlSync(props),
@@ -395,7 +479,7 @@ describe('useUrlSync', () => {
});
expect(result.current.routeDetailError).not.toBeNull();
loadFileForRoute.mockResolvedValue(true);
loadFileForRoute.mockResolvedValue({ ok: true, envFiles: [] });
rerender(makeOpts({
isMobile: true,
@@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback, useState, type MutableRefObject } from
import type { Node } from '@/context/NodeContext';
import type { FleetTab, SecurityTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ActiveView, EditorTab, MobileRouteSurface } from '@/lib/router/routeTypes';
import type { ActiveView, EditorTab, MobileRouteSurface, RouteStackLoadResult } from '@/lib/router/routeTypes';
import { buildPath, parsePath } from '@/lib/router/senchoRoute';
import { envFileForRouteUrl, resolveEnvRouteTarget } from '@/lib/router/envRoute';
import { nodeIdToSlug, slugToNodeId } from '@/lib/nodeSlug';
@@ -60,7 +60,7 @@ export interface UseUrlSyncOptions {
setActiveTab: (tab: EditorTab) => void;
selectedEnvFile: string;
envFiles: string[];
loadFileForRoute: (filename: string) => Promise<boolean>;
loadFileForRoute: (filename: string) => Promise<RouteStackLoadResult>;
changeEnvFile: (file: string) => Promise<void>;
applyEditorRouteState: (tab: EditorTab) => void;
refreshStacks: (background?: boolean) => Promise<string[]>;
@@ -86,10 +86,20 @@ function readIdx(state: unknown): number | null {
return null;
}
function pendingNodeMatchesActive(pendingNodeId: number | null, activeNodeId: number | undefined): boolean {
return pendingNodeId == null || activeNodeId === pendingNodeId;
}
interface PendingEditorRouteOpts {
envFiles: string[];
inventoryReady: boolean;
}
async function applyPendingEditorRoute(
optsRef: MutableRefObject<UseUrlSyncOptions>,
pendingEnvRef: MutableRefObject<string | null>,
tab: EditorTab,
routeOpts?: PendingEditorRouteOpts,
): Promise<boolean> {
const live = optsRef.current;
if (tab !== 'env') {
@@ -98,14 +108,26 @@ async function applyPendingEditorRoute(
live.applyEditorRouteState(tab);
return true;
}
const outcome = resolveEnvRouteTarget(pendingEnvRef.current, live.envFiles);
const envFiles = routeOpts?.envFiles ?? live.envFiles;
const inventoryReady = routeOpts?.inventoryReady
?? (!live.isFileLoading && live.selectedFile != null);
const stackLoading = live.isFileLoading && routeOpts?.envFiles == null;
const outcome = resolveEnvRouteTarget(
pendingEnvRef.current,
envFiles,
stackLoading,
inventoryReady,
);
if (!outcome.ready) return false;
pendingEnvRef.current = null;
if (outcome.target && outcome.target !== live.selectedEnvFile) {
let effectiveTab: EditorTab = tab;
if (outcome.target == null && envFiles.length === 0) {
effectiveTab = 'compose';
} else if (outcome.target && outcome.target !== live.selectedEnvFile) {
await live.changeEnvFile(outcome.target);
}
live.setActiveTab(tab);
live.applyEditorRouteState(tab);
live.setActiveTab(effectiveTab);
live.applyEditorRouteState(effectiveTab);
return true;
}
@@ -168,6 +190,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
const o = optsRef.current;
const pending = pendingRouteRef.current;
if (!pending) return;
if (!pendingNodeMatchesActive(pendingNodeIdRef.current, o.activeNode?.id)) return;
let view = pending.view;
if (authzReady(o.reachCtx)) {
@@ -221,6 +244,7 @@ export function useUrlSync(options: UseUrlSyncOptions) {
const o = optsRef.current;
const stack = pendingStackRef.current;
if (!stack || !o.activeNode) return;
if (!pendingNodeMatchesActive(pendingNodeIdRef.current, o.activeNode.id)) return;
if (o.filesNodeId !== o.activeNode.id) return;
if (o.stacksLoadStatus === 'loading' || o.stacksLoadStatus === 'idle') return;
@@ -250,7 +274,10 @@ export function useUrlSync(options: UseUrlSyncOptions) {
// failure, which would hide the recovery chip during a deploy).
if (o.selectedFile === match) {
const tab = pendingTabRef.current ?? 'compose';
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab);
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab, {
envFiles: o.envFiles,
inventoryReady: !o.isFileLoading,
});
if (!applied) return;
pendingStackRef.current = null;
pendingTabRef.current = null;
@@ -264,10 +291,10 @@ export function useUrlSync(options: UseUrlSyncOptions) {
resolvingRef.current = true;
const attempted = match;
const loaded = await o.loadFileForRoute(match);
const loadResult = await o.loadFileForRoute(match);
if (pendingStackRef.current !== attempted) { resolvingRef.current = false; return; }
if (!loaded) {
if (!loadResult.ok) {
phaseRef.current = 'frozen';
pendingRouteRef.current = null;
setRouteDetailError(`Could not open "${attempted.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`);
@@ -276,7 +303,10 @@ export function useUrlSync(options: UseUrlSyncOptions) {
}
const tab = pendingTabRef.current ?? 'compose';
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab);
const applied = await applyPendingEditorRoute(optsRef, pendingEnvRef, tab, {
envFiles: loadResult.envFiles,
inventoryReady: true,
});
if (!applied) {
resolvingRef.current = false;
return;
@@ -393,9 +423,9 @@ export function useUrlSync(options: UseUrlSyncOptions) {
}, [hydrateFromUrl, options.nodesLoaded, options.nodes.length]);
useEffect(() => {
if (pendingRouteRef.current && optsRef.current.activeNode) {
void applyPendingRoute();
}
if (!pendingRouteRef.current || !optsRef.current.activeNode) return;
if (!pendingNodeMatchesActive(pendingNodeIdRef.current, optsRef.current.activeNode.id)) return;
void applyPendingRoute();
}, [applyPendingRoute, options.activeNode?.id]);
useEffect(() => {
+17 -5
View File
@@ -38,19 +38,31 @@ describe('envRoute', () => {
expect(normalizeEnvFileQuery('')).toBeNull();
});
it('resolveEnvRouteTarget waits for envFiles and falls back to default', () => {
expect(resolveEnvRouteTarget('.env.prod', [])).toEqual({ ready: false });
expect(resolveEnvRouteTarget('.env.prod', envFiles)).toEqual({
it('resolveEnvRouteTarget waits for inventory and falls back to default', () => {
expect(resolveEnvRouteTarget('.env.prod', [], true, false)).toEqual({ ready: false });
expect(resolveEnvRouteTarget('.env.prod', [], false, false)).toEqual({ ready: false });
expect(resolveEnvRouteTarget('.env.prod', envFiles, false, true)).toEqual({
ready: true,
target: '/home/user/compose/radarr/.env.prod',
});
expect(resolveEnvRouteTarget(null, envFiles)).toEqual({
expect(resolveEnvRouteTarget(null, envFiles, false, true)).toEqual({
ready: true,
target: '/home/user/compose/radarr/.env',
});
expect(resolveEnvRouteTarget('.env.missing', envFiles)).toEqual({
expect(resolveEnvRouteTarget('.env.missing', envFiles, false, true)).toEqual({
ready: true,
target: '/home/user/compose/radarr/.env',
});
});
it('resolveEnvRouteTarget settles when env inventory is empty', () => {
expect(resolveEnvRouteTarget('.env.prod', [], false, true)).toEqual({
ready: true,
target: null,
});
expect(resolveEnvRouteTarget(null, [], false, true)).toEqual({
ready: true,
target: null,
});
});
});
+10 -4
View File
@@ -26,7 +26,7 @@ export function envFileForRouteUrl(
const first = envFiles[0];
if (first && selectedEnvFile === first) return null;
const basename = envFileBasename(selectedEnvFile);
if (!basename || basename.includes('/')) return null;
if (!basename || /[\\/]/.test(basename)) return null;
return basename;
}
@@ -42,9 +42,15 @@ export type EnvRouteTarget =
| { ready: false }
| { ready: true; target: string | null };
/** Pick the env file to open from a route token once envFiles are loaded. */
export function resolveEnvRouteTarget(requested: string | null, envFiles: string[]): EnvRouteTarget {
if (envFiles.length === 0) return { ready: false };
/** Pick the env file to open from a route token once env inventory is known. */
export function resolveEnvRouteTarget(
requested: string | null,
envFiles: string[],
stackLoading = false,
inventoryReady = true,
): EnvRouteTarget {
if (stackLoading || !inventoryReady) return { ready: false };
if (envFiles.length === 0) return { ready: true, target: null };
const defaultFile = envFiles[0];
if (!requested) return { ready: true, target: defaultFile };
const resolved = resolveEnvFilePath(requested, envFiles);
+5
View File
@@ -44,6 +44,11 @@ export interface RouteState {
isMobile: boolean;
}
/** Result of loading a stack for URL hydration (includes env inventory snapshot). */
export type RouteStackLoadResult =
| { ok: false }
| { ok: true; envFiles: string[] };
export interface ParsedRoute {
nodeSlug: string | null;
view: ActiveView | null;
@@ -115,6 +115,17 @@ describe('senchoRoute', () => {
expect(path).toBe('/nodes/local/stacks/radarr/env');
});
it('rejects Windows paths in buildPath env query', () => {
const path = buildPath({
...base,
activeView: 'editor',
stackName: 'radarr',
editorTab: 'env',
envFile: 'C:\\compose\\stack\\.env.prod',
});
expect(path).toBe('/nodes/local/stacks/radarr/env');
});
it('parses stack list path as mobile list surface', () => {
const parsed = parsePath('/nodes/local/stacks', '');
expect(parsed.isStackList).toBe(true);
+1 -1
View File
@@ -140,7 +140,7 @@ export function buildPath(state: RouteState): string {
if (state.activeView === 'editor' && state.stackName) {
const tab = state.editorTab || 'compose';
url.pathname = `${base}/stacks/${encodeURIComponent(state.stackName)}/${tab}`;
if (tab === 'env' && state.envFile && !state.envFile.includes('/')) {
if (tab === 'env' && state.envFile && !/[\\/]/.test(state.envFile)) {
url.searchParams.set('env', state.envFile);
}
if (state.filterNodeId != null) {