mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
b9d8e9f490
* feat(stacks): browse and edit mounted volume files in the explorer Reposition the stack file explorer around runtime configuration access: discover a stack's declared mounts and expose each as a safe, stack-scoped file root. The explorer opens on a Volumes group (bind mounts and named Docker volumes) by default, with the stack source directory as a secondary group, on a "Files & Volumes" tab. - Discover roots from the rendered effective compose model; resolve named volumes to their Docker name and browse/edit them through the hardened helper container, with bind mounts handled directly when reachable. - Re-derive the allowed roots server-side on every file operation and match the client root id against them, so a request can never address a path the stack did not declare. Block dangerous host mounts and binds that overlap Sencho's managed directories; reject writes to read-only mounts. - Thread an optional root id through the existing file endpoints and an opaque, parseable optimistic-concurrency token through read, conflict, and write, for both filesystem and helper backends. - Keep compose and env file protection on the stack source root only. * fix(stacks): theme the Files & Volumes root switcher Replace the raw native select in the file-root switcher with the design system Select component. The native control did not honour the dark theme, so the panel rendered white with unreadable text. The themed Select gives a dark popover with grouped Volumes / Stack source labels and disabled items. * fix(stacks): contain the bind-root probe and de-taint the file-op error log Gate the volume-root bind probe's realpath/stat behind a compose-base containment check (mirroring the storage host-path probe) so they never run on an unvalidated host path; a source outside the compose dir is unreachable in the containerized deployment anyway and is reported non-accessible without touching the filesystem. Log the helper-backed file-op failure through a constant format string with sanitized arguments instead of an interpolated template literal. * fix(stacks): inline the bind-probe containment guard at the fs sinks The wrapped containment predicate was not recognized as a path barrier, so the bind probe's realpath/stat still flagged as uncontrolled-data-in-path. Inline the path.resolve + startsWith check directly at each filesystem sink (and re-check the resolved canonical before stat, so a within-base symlink that resolves outside the compose dir is also rejected). * fix(stacks): harden file-root lifecycle, upload race, and helper errors Address review findings on the Files & Volumes feature: - Invalidate the file-root allowlist on stack create/delete/import/from-git (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a stack deleted and recreated under the same name cannot serve the old stack's roots from the 15s cache. - Use the atomic exclusive write for a non-overwrite upload so a file created by another writer after the existence check is not silently clobbered. - Let the helper's real cd errno through and map permission failures to 403 consistently across list/stat/read/write/mkdir/delete/pathKind, instead of reporting EACCES as 404/500; pathKind no longer reports a permission-denied parent as absent. - Document the realpath-then-open TOCTOU as a known, pre-existing limitation of every file op (O_NOFOLLOW is not viable because config volumes legitimately contain symlinks); the bind root is contained to the compose dir and the op requires stack:edit. - Docs: drop a missing screenshot reference and correct the protected-file delete behavior (stack-root compose/.env cannot be deleted via the explorer).
194 lines
6.3 KiB
TypeScript
194 lines
6.3 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
|
|
import { Button } from '@/components/ui/button';
|
|
import { toast } from '@/components/ui/toast-store';
|
|
import { getStackEntryPermissions, setStackEntryPermissions } from '@/lib/stackFilesApi';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bit mapping: owner (6-8), group (3-5), other (0-2)
|
|
// Within each set: read=4, write=2, execute=1
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface BitInfo {
|
|
label: 'r' | 'w' | 'x';
|
|
shift: number; // bit position
|
|
}
|
|
|
|
const BITS: BitInfo[] = [
|
|
{ label: 'r', shift: 2 },
|
|
{ label: 'w', shift: 1 },
|
|
{ label: 'x', shift: 0 },
|
|
];
|
|
|
|
interface Category {
|
|
label: string;
|
|
baseShift: number; // owner=6, group=3, other=0
|
|
}
|
|
|
|
const CATEGORIES: Category[] = [
|
|
{ label: 'Owner', baseShift: 6 },
|
|
{ label: 'Group', baseShift: 3 },
|
|
{ label: 'Other', baseShift: 0 },
|
|
];
|
|
|
|
function getBit(mode: number, totalShift: number): boolean {
|
|
return Boolean(mode & (1 << totalShift));
|
|
}
|
|
|
|
function toggleBit(mode: number, totalShift: number): number {
|
|
return mode ^ (1 << totalShift);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface FilePermissionsDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
stackName: string;
|
|
relPath: string;
|
|
entryName: string;
|
|
rootId?: string;
|
|
canEdit: boolean;
|
|
}
|
|
|
|
export function FilePermissionsDialog({
|
|
open,
|
|
onOpenChange,
|
|
stackName,
|
|
relPath,
|
|
entryName,
|
|
rootId,
|
|
canEdit,
|
|
}: FilePermissionsDialogProps) {
|
|
const [mode, setMode] = useState<number>(0o644);
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const result = await getStackEntryPermissions(stackName, relPath, rootId);
|
|
setMode(result.mode);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Failed to load permissions.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [stackName, relPath, rootId]);
|
|
|
|
useEffect(() => {
|
|
if (open) void load();
|
|
}, [open, load]);
|
|
|
|
const handleClose = (next: boolean) => {
|
|
if (saving) return;
|
|
onOpenChange(next);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await setStackEntryPermissions(stackName, relPath, mode, rootId);
|
|
toast.success('Permissions updated.');
|
|
onOpenChange(false);
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : 'Failed to update permissions.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const octal = mode.toString(8).padStart(3, '0');
|
|
const canModify = canEdit;
|
|
|
|
return (
|
|
<Modal open={open} onOpenChange={handleClose} size="sm">
|
|
<ModalHeader
|
|
kicker={`${stackName.toUpperCase()} · PERMISSIONS`}
|
|
title="Permissions"
|
|
description={`Unix permission bits for ${entryName}.`}
|
|
/>
|
|
<ModalBody>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
|
</div>
|
|
) : error ? (
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* bit grid */}
|
|
<div className="grid grid-cols-4 gap-x-3 gap-y-2 text-xs">
|
|
{/* header row */}
|
|
<div /> {/* empty corner */}
|
|
{BITS.map((b) => (
|
|
<div key={b.label} className="text-center font-mono font-medium text-muted-foreground uppercase tracking-wider">
|
|
{b.label}
|
|
</div>
|
|
))}
|
|
{/* category rows */}
|
|
{CATEGORIES.map((cat) => (
|
|
<>
|
|
<div key={cat.label} className="text-muted-foreground flex items-center">{cat.label}</div>
|
|
{BITS.map((bit) => {
|
|
const totalShift = cat.baseShift + bit.shift;
|
|
const checked = getBit(mode, totalShift);
|
|
return (
|
|
<button
|
|
key={bit.label}
|
|
type="button"
|
|
disabled={!canModify || saving}
|
|
onClick={() => setMode((m) => toggleBit(m, totalShift))}
|
|
className={cn(
|
|
'mx-auto flex h-7 w-7 items-center justify-center rounded-md border text-xs font-mono transition-colors',
|
|
checked
|
|
? 'border-primary/60 bg-primary/10 text-primary'
|
|
: 'border-border bg-muted/30 text-muted-foreground',
|
|
canModify && !saving && 'hover:border-primary/50 cursor-pointer',
|
|
(!canModify || saving) && 'opacity-50 cursor-not-allowed'
|
|
)}
|
|
aria-label={`${cat.label} ${bit.label} ${checked ? 'on' : 'off'}`}
|
|
>
|
|
{bit.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</>
|
|
))}
|
|
</div>
|
|
|
|
{/* octal summary */}
|
|
<div className="flex items-center justify-between rounded-md border border-border bg-muted/20 px-3 py-2">
|
|
<span className="text-xs text-muted-foreground">Octal</span>
|
|
<span className="font-mono text-sm tracking-widest">{octal}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</ModalBody>
|
|
<ModalFooter
|
|
secondary={
|
|
<Button variant="outline" size="sm" onClick={() => handleClose(false)} disabled={saving}>
|
|
{canModify ? 'Cancel' : 'Close'}
|
|
</Button>
|
|
}
|
|
primary={
|
|
canModify ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void handleSave()}
|
|
disabled={saving || loading}
|
|
>
|
|
{saving && <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />}
|
|
Save
|
|
</Button>
|
|
) : null
|
|
}
|
|
/>
|
|
</Modal>
|
|
);
|
|
}
|