fix: harden stack file explorer operations (#1028)

* fix: harden stack file explorer operations

* fix: update Docker toolchain to Go 1.26.3

* fix: repair Dockerfile tr argument split across lines

* fix: bump protobufjs to clear npm audit high-severity advisories
This commit is contained in:
Anso
2026-05-12 15:49:51 -04:00
committed by GitHub
parent 19cdb3681d
commit 69b6ac1f3b
8 changed files with 327 additions and 39 deletions
@@ -50,6 +50,7 @@ interface FilePermissionsDialogProps {
relPath: string;
entryName: string;
isPaid: boolean;
canEdit: boolean;
}
export function FilePermissionsDialog({
@@ -59,6 +60,7 @@ export function FilePermissionsDialog({
relPath,
entryName,
isPaid,
canEdit,
}: FilePermissionsDialogProps) {
const [mode, setMode] = useState<number>(0o644);
const [loading, setLoading] = useState(false);
@@ -101,6 +103,7 @@ export function FilePermissionsDialog({
};
const octal = mode.toString(8).padStart(3, '0');
const canModify = isPaid && canEdit;
return (
<Modal open={open} onOpenChange={handleClose} size="sm">
@@ -138,15 +141,15 @@ export function FilePermissionsDialog({
<button
key={bit.label}
type="button"
disabled={!isPaid || saving}
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',
isPaid && !saving && 'hover:border-primary/50 cursor-pointer',
(!isPaid || saving) && 'opacity-50 cursor-not-allowed'
canModify && !saving && 'hover:border-primary/50 cursor-pointer',
(!canModify || saving) && 'opacity-50 cursor-not-allowed'
)}
aria-label={`${cat.label} ${bit.label} ${checked ? 'on' : 'off'}`}
>
@@ -169,11 +172,11 @@ export function FilePermissionsDialog({
<ModalFooter
secondary={
<Button variant="outline" size="sm" onClick={() => handleClose(false)} disabled={saving}>
{isPaid ? 'Cancel' : 'Close'}
{canModify ? 'Cancel' : 'Close'}
</Button>
}
primary={
isPaid ? (
canModify ? (
<Button
size="sm"
onClick={() => void handleSave()}
@@ -35,6 +35,7 @@ export function FileTreeContextMenu({
children,
}: FileTreeContextMenuProps) {
const isDir = entry.type === 'directory';
const canWrite = canEdit && isPaid;
return (
<ContextMenu>
@@ -42,7 +43,7 @@ export function FileTreeContextMenu({
<ContextMenuContent className="min-w-[180px]">
{isDir ? (
<>
{isPaid && (
{canWrite && (
<>
<ContextMenuItem
onSelect={() => onRequestNewFile(relPath)}
@@ -59,13 +60,13 @@ export function FileTreeContextMenu({
<ContextMenuSeparator />
</>
)}
{canEdit && (
{canWrite && (
<ContextMenuItem onSelect={() => onRequestRename(relPath)}>
<Pencil className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>Rename</span>
</ContextMenuItem>
)}
{canEdit && (
{canWrite && (
<>
<ContextMenuSeparator />
<ContextMenuItem
@@ -80,7 +81,7 @@ export function FileTreeContextMenu({
</>
) : (
<>
{canEdit && (
{canWrite && (
<ContextMenuItem onSelect={() => onRequestRename(relPath)}>
<Pencil className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>Rename</span>
@@ -90,7 +91,7 @@ export function FileTreeContextMenu({
<Lock className="h-4 w-4 mr-2" strokeWidth={1.5} />
<span>Permissions</span>
</ContextMenuItem>
{canEdit && (
{canWrite && (
<>
<ContextMenuSeparator />
<ContextMenuItem
@@ -9,18 +9,20 @@ const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
interface FileUploadDropzoneProps {
stackName: string;
currentDir: string;
canEdit: boolean;
onUploaded: () => void;
}
export function FileUploadDropzone({
stackName,
currentDir,
canEdit,
onUploaded,
}: FileUploadDropzoneProps) {
const { isPaid } = useLicense();
const inputRef = useRef<HTMLInputElement>(null);
if (!isPaid) return null;
if (!isPaid || !canEdit) return null;
const handleFile = async (file: File) => {
if (file.size > MAX_BYTES) {
@@ -150,10 +150,11 @@ export function StackFileExplorer({
<FileUploadDropzone
stackName={stackName}
currentDir={currentDir}
canEdit={canEdit}
onUploaded={refresh}
/>
</div>
{isPaid && (
{isPaid && canEdit && (
<Button
variant="ghost"
size="icon"
@@ -207,16 +208,18 @@ export function StackFileExplorer({
)}
Download
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-destructive hover:text-destructive hover:bg-destructive/10"
data-testid="file-action-delete"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
Delete
</Button>
{canEdit && (
<Button
variant="ghost"
size="sm"
className="h-7 text-destructive hover:text-destructive hover:bg-destructive/10"
data-testid="file-action-delete"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
Delete
</Button>
)}
</div>
)}
<div className="flex-1 min-h-0">
@@ -297,6 +300,7 @@ export function StackFileExplorer({
relPath={permissionsRelPath}
entryName={permissionsEntryName}
isPaid={isPaid}
canEdit={canEdit}
/>
</div>
);
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { FileUploadDropzone } from '../FileUploadDropzone';
const licenseState = { isPaid: true };
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => licenseState,
}));
vi.mock('@/lib/stackFilesApi', () => ({
uploadStackFile: vi.fn(),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
loading: vi.fn(() => 'loading-id'),
dismiss: vi.fn(),
},
}));
describe('FileUploadDropzone', () => {
beforeEach(() => {
licenseState.isPaid = true;
});
it('renders upload control for paid users with stack edit permission', () => {
render(
<FileUploadDropzone
stackName="app"
currentDir=""
canEdit
onUploaded={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
});
it('hides upload control when the user cannot edit the stack', () => {
render(
<FileUploadDropzone
stackName="app"
currentDir=""
canEdit={false}
onUploaded={vi.fn()}
/>,
);
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
});
it('hides upload control on Community tier', () => {
licenseState.isPaid = false;
render(
<FileUploadDropzone
stackName="app"
currentDir=""
canEdit
onUploaded={vi.fn()}
/>,
);
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
});
});