Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions app/api/files/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ import {
validateUploadFileNames,
} from "@/lib/file-upload";
import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data";
import trash from "trash";
import {
FileOpError,
authorizeExistingPath,
isAncestorOrSelf,
validateSingleFileName,
} from "@/lib/file-ops";

const IGNORED_NAMES = new Set([
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
Expand Down Expand Up @@ -150,6 +157,38 @@ export async function POST(
return NextResponse.json(inspectUploadTargets(directory, fileNames));
}

if (type === "create") {
const body = await request.json().catch(() => null) as { name?: unknown; isDir?: unknown } | null;
const name = typeof body?.name === "string" ? body.name : null;
if (!name) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const nameError = validateSingleFileName(name);
if (nameError) {
return NextResponse.json({ error: nameError }, { status: 400 });
}
const target = path.join(directory, name);
if (fs.existsSync(target)) {
return NextResponse.json(
{ error: "A file or folder with that name already exists", exists: true },
{ status: 409 },
);
}
try {
if (Boolean(body?.isDir)) {
fs.mkdirSync(target);
} else {
fs.writeFileSync(target, "", { flag: "wx" });
}
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 500 },
);
}
return NextResponse.json({ ok: true, path: target });
}

if (type !== "upload") {
return NextResponse.json({ error: "Invalid upload request type" }, { status: 400 });
}
Expand Down Expand Up @@ -241,6 +280,128 @@ export async function POST(
}
}

function fileOpErrorResponse(error: unknown): NextResponse {
if (error instanceof FileOpError) {
return NextResponse.json({ error: error.message, ...(error.extra ?? {}) }, { status: error.status });
}
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 500 },
);
}

// Move a file or folder to the system trash. The path is resolved through
// symlinks (authorizeExistingPath) so a symlink inside an allowed root cannot
// redirect the deletion outside it.
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
if (!isApiRequestAllowed(request)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}

try {
const { path: segments } = await params;
const filePath = filePathFromSegments(segments);

let realPath: string;
try {
realPath = await authorizeExistingPath(filePath);
} catch (error) {
return fileOpErrorResponse(error);
}

try {
await trash(realPath);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 500 },
);
}
Comment on lines +315 to +322

return NextResponse.json({ ok: true });
} catch (error) {
return fileOpErrorResponse(error);
}
}

// Rename or move a file/folder. Body: { to: string } where `to` is the new
// absolute path. Rename and move are the same operation (a path change). v1
// rejects overwrite (409) and never copies - plain drag is always a move.
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
if (!isApiRequestAllowed(request)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}

try {
const { path: segments } = await params;
const source = filePathFromSegments(segments);

const body = await request.json().catch(() => null) as { to?: unknown } | null;
const to = typeof body?.to === "string" ? body.to : null;
if (!to) {
return NextResponse.json({ error: "Missing \"to\" path" }, { status: 400 });
}
if (to.includes("\0")) {
return NextResponse.json({ error: "Invalid target path" }, { status: 400 });
}
const nameError = validateSingleFileName(path.basename(to));
if (nameError) {
return NextResponse.json({ error: nameError }, { status: 400 });
}
if (to === source) {
return NextResponse.json({ error: "Target is the same as the source" }, { status: 400 });
}

let realSource: string;
let realTargetParent: string;
try {
realSource = await authorizeExistingPath(source);
realTargetParent = await authorizeExistingPath(path.dirname(to));
} catch (error) {
return fileOpErrorResponse(error);
}

const realTarget = path.join(realTargetParent, path.basename(to));

// Block moving a folder into itself or one of its descendants.
if (isAncestorOrSelf(realSource, realTarget)) {
return NextResponse.json(
{ error: "Cannot move a folder into itself" },
{ status: 400 },
);
}

if (fs.existsSync(realTarget)) {
return NextResponse.json(
{ error: "A file or folder with that name already exists", exists: true },
{ status: 409 },
);
}

try {
fs.renameSync(realSource, realTarget);
} catch (error) {
// Cross-device rename (e.g. tmpfs -> disk). Fall back to copy + remove.
if ((error as NodeJS.ErrnoException).code === "EXDEV") {
fs.cpSync(realSource, realTarget, { recursive: true });
fs.rmSync(realSource, { recursive: true, force: true });
} else {
throw error;
}
}
Comment on lines +387 to +397

return NextResponse.json({ ok: true, from: realSource, to: realTarget });
} catch (error) {
return fileOpErrorResponse(error);
}
}

function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {
const fileStream = fs.createReadStream(filePath, range);
let closed = false;
Expand Down
137 changes: 137 additions & 0 deletions components/FileContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"use client";

import { Fragment, useEffect, useLayoutEffect, useRef, useState } from "react";

export interface ContextMenuItem {
key: string;
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
/** Render a separator below this item. */
separatorAfter?: boolean;
}

interface Props {
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
}

/**
* A lightweight, portal-free context menu positioned at the cursor. Closes on
* outside click, Escape, scroll, or window blur. Clamps itself inside the
* viewport so it never overflows the right/bottom edge.
*/
export function FileContextMenu({ x, y, items, onClose }: Props) {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x, y });

// Clamp into the viewport once measured.
useLayoutEffect(() => {
const el = menuRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const margin = 4;
let nextX = x;
let nextY = y;
if (x + rect.width > window.innerWidth - margin) {
nextX = Math.max(margin, window.innerWidth - rect.width - margin);
}
if (y + rect.height > window.innerHeight - margin) {
nextY = Math.max(margin, window.innerHeight - rect.height - margin);
}
setPosition({ x: nextX, y: nextY });
}, [x, y]);

useEffect(() => {
const handlePointerDown = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
// Close on scroll so the menu never detaches from its anchor point.
const handleScroll = () => onClose();
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", onClose);
window.addEventListener("blur", onClose);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", onClose);
window.removeEventListener("blur", onClose);
};
}, [onClose]);

return (
<div
ref={menuRef}
role="menu"
style={{
position: "fixed",
left: position.x,
top: position.y,
zIndex: 1200,
minWidth: 168,
maxWidth: 240,
padding: 4,
border: "1px solid var(--border)",
borderRadius: 6,
background: "var(--bg-panel)",
boxShadow: "0 8px 24px rgba(0,0,0,0.22)",
}}
>
{items.map((item) => (
<Fragment key={item.key}>
<button
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
if (item.disabled) return;
item.onClick();
onClose();
}}
style={{
display: "flex",
alignItems: "center",
width: "100%",
padding: "5px 10px",
border: "none",
borderRadius: 4,
background: "transparent",
color: item.disabled
? "var(--text-dim)"
: item.danger
? "#f87171"
: "var(--text)",
cursor: item.disabled ? "default" : "pointer",
fontSize: 12,
textAlign: "left",
whiteSpace: "nowrap",
}}
onMouseEnter={(event) => {
if (item.disabled) return;
event.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = "transparent";
}}
>
{item.label}
</button>
{item.separatorAfter && (
<div style={{ height: 1, margin: "4px 0", background: "var(--border)" }} />
)}
</Fragment>
))}
</div>
);
}
Loading