mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
612 lines
21 KiB
TypeScript
612 lines
21 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import {
|
|
getAllowedFileRoots,
|
|
isExistingFilePathAllowed,
|
|
isFilePathAllowed,
|
|
isWindowsAbsolutePath,
|
|
normalizeSlashes,
|
|
} from "@/lib/file-access";
|
|
import {
|
|
DOCX_PREVIEW_MAX_BYTES,
|
|
IMAGE_PREVIEW_MAX_BYTES,
|
|
TEXT_PREVIEW_MAX_BYTES,
|
|
documentPreviewKind,
|
|
getAudioMime,
|
|
getDocumentMime,
|
|
getFileExt,
|
|
getImageMime,
|
|
} from "@/lib/file-types";
|
|
import { resolveDirentIsDirectory } from "@/lib/file-dirent";
|
|
import { isFilePathReferencedBySession } from "@/lib/session-file-references";
|
|
import { isApiRequestAllowed } from "@/lib/request-security";
|
|
import {
|
|
inspectUploadTargets,
|
|
parseUploadConflictStrategy,
|
|
validateUploadFileNames,
|
|
} from "@/lib/file-upload";
|
|
import { parseFormDataWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data";
|
|
|
|
const IGNORED_NAMES = new Set([
|
|
"node_modules", ".git", ".next", "dist", "build", "__pycache__",
|
|
".turbo", ".cache", "coverage", ".pytest_cache", ".mypy_cache",
|
|
"target", "vendor", ".DS_Store", ".git",
|
|
]);
|
|
|
|
const IGNORED_SUFFIXES = [".pyc"];
|
|
|
|
const FILE_REQUEST_TYPES = ["list", "read", "download", "meta", "preview", "watch"] as const;
|
|
type FileRequestType = typeof FILE_REQUEST_TYPES[number];
|
|
const FILE_REQUEST_TYPE_SET = new Set<string>(FILE_REQUEST_TYPES);
|
|
const MAX_UPLOAD_FILE_BYTES = 25 * 1024 * 1024;
|
|
const MAX_UPLOAD_TOTAL_BYTES = 100 * 1024 * 1024;
|
|
// Multipart boundaries and headers are not file bytes, but must be bounded too.
|
|
const MAX_UPLOAD_REQUEST_BYTES = MAX_UPLOAD_TOTAL_BYTES + 1024 * 1024;
|
|
|
|
const EXT_TO_LANGUAGE: Record<string, string> = {
|
|
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
|
mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby",
|
|
go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
|
|
c: "c", cpp: "cpp", h: "c", hpp: "cpp", cs: "csharp",
|
|
html: "html", htm: "html", css: "css", scss: "css", less: "css",
|
|
json: "json", jsonl: "json", yaml: "yaml", yml: "yaml",
|
|
toml: "toml", xml: "xml", md: "markdown", mdx: "markdown",
|
|
sh: "bash", bash: "bash", zsh: "bash", fish: "bash",
|
|
sql: "sql", graphql: "graphql", gql: "graphql",
|
|
dockerfile: "dockerfile", tf: "hcl", hcl: "hcl",
|
|
env: "bash", gitignore: "bash", txt: "text",
|
|
pdf: "pdf", docx: "word",
|
|
};
|
|
|
|
function getLanguage(filePath: string): string {
|
|
const base = path.basename(filePath).toLowerCase();
|
|
// Special full-name matches
|
|
if (base === "dockerfile" || base.startsWith("dockerfile.")) return "dockerfile";
|
|
if (base === ".env" || base.startsWith(".env.")) return "bash";
|
|
if (base === "makefile" || base === "gnumakefile") return "makefile";
|
|
const ext = base.split(".").pop() ?? "";
|
|
return EXT_TO_LANGUAGE[ext] ?? "text";
|
|
}
|
|
|
|
function filePathFromSegments(segments: string[]): string {
|
|
const joined = segments.join("/");
|
|
const slashJoined = normalizeSlashes(joined);
|
|
if (isWindowsAbsolutePath(slashJoined)) return slashJoined;
|
|
return "/" + joined.replace(/^\/+/, "");
|
|
}
|
|
|
|
function parseFileRequestType(value: string): FileRequestType | null {
|
|
return FILE_REQUEST_TYPE_SET.has(value) ? (value as FileRequestType) : null;
|
|
}
|
|
|
|
async function getUploadDirectory(segments: string[]): Promise<
|
|
{ directory: string } | { response: NextResponse }
|
|
> {
|
|
const directory = filePathFromSegments(segments);
|
|
const allowedRoots = await getAllowedFileRoots();
|
|
if (!isFilePathAllowed(directory, allowedRoots)) {
|
|
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
|
}
|
|
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.statSync(directory);
|
|
} catch {
|
|
return { response: NextResponse.json({ error: "Upload directory not found" }, { status: 404 }) };
|
|
}
|
|
if (!stat.isDirectory()) {
|
|
return { response: NextResponse.json({ error: "Upload target is not a directory" }, { status: 400 }) };
|
|
}
|
|
|
|
// A browsable directory can be a symlink. Resolve both sides before writes
|
|
// so a symlink inside an allowed root cannot redirect uploads outside it.
|
|
const realDirectory = fs.realpathSync(directory);
|
|
const realRoots = new Set<string>();
|
|
for (const root of allowedRoots) {
|
|
try {
|
|
realRoots.add(fs.realpathSync(root));
|
|
} catch {
|
|
// Ignore stale session roots that no longer exist.
|
|
}
|
|
}
|
|
if (!isFilePathAllowed(realDirectory, realRoots)) {
|
|
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
|
|
}
|
|
|
|
return { directory: realDirectory };
|
|
}
|
|
|
|
function parseUploadFileNames(value: unknown): string[] | null {
|
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return null;
|
|
return value;
|
|
}
|
|
|
|
export async function POST(
|
|
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 uploadDirectory = await getUploadDirectory(segments);
|
|
if ("response" in uploadDirectory) return uploadDirectory.response;
|
|
const { directory } = uploadDirectory;
|
|
const type = request.nextUrl.searchParams.get("type") ?? "upload";
|
|
|
|
if (type === "upload-check") {
|
|
const body = await request.json().catch(() => null) as { fileNames?: unknown } | null;
|
|
const fileNames = parseUploadFileNames(body?.fileNames);
|
|
if (!fileNames) {
|
|
return NextResponse.json({ error: "fileNames must be an array of strings" }, { status: 400 });
|
|
}
|
|
const validationError = validateUploadFileNames(fileNames);
|
|
if (validationError) {
|
|
return NextResponse.json({ error: validationError }, { status: 400 });
|
|
}
|
|
return NextResponse.json(inspectUploadTargets(directory, fileNames));
|
|
}
|
|
|
|
if (type !== "upload") {
|
|
return NextResponse.json({ error: "Invalid upload request type" }, { status: 400 });
|
|
}
|
|
|
|
const strategy = parseUploadConflictStrategy(request.nextUrl.searchParams.get("conflict"));
|
|
if (!strategy) {
|
|
return NextResponse.json({ error: "Invalid conflict strategy" }, { status: 400 });
|
|
}
|
|
|
|
let formData: FormData;
|
|
try {
|
|
formData = await parseFormDataWithinLimit(request, MAX_UPLOAD_REQUEST_BYTES);
|
|
} catch (error) {
|
|
if (error instanceof RequestBodyTooLargeError) {
|
|
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
|
}
|
|
throw error;
|
|
}
|
|
const files = formData.getAll("files").filter((entry): entry is File => typeof entry !== "string");
|
|
if (files.some((file) => file.size > MAX_UPLOAD_FILE_BYTES)) {
|
|
return NextResponse.json({ error: "Each upload must be 25MB or smaller" }, { status: 413 });
|
|
}
|
|
if (files.reduce((total, file) => total + file.size, 0) > MAX_UPLOAD_TOTAL_BYTES) {
|
|
return NextResponse.json({ error: "Uploads must total 100MB or less" }, { status: 413 });
|
|
}
|
|
const fileNames = files.map((file) => file.name);
|
|
const validationError = validateUploadFileNames(fileNames);
|
|
if (validationError) {
|
|
return NextResponse.json({ error: validationError }, { status: 400 });
|
|
}
|
|
|
|
const inspection = inspectUploadTargets(directory, fileNames);
|
|
if (strategy === "error" && inspection.conflicts.length > 0) {
|
|
return NextResponse.json({
|
|
error: "One or more files already exist",
|
|
conflicts: inspection.conflicts,
|
|
nonReplaceable: inspection.nonReplaceable,
|
|
}, { status: 409 });
|
|
}
|
|
|
|
const conflictSet = new Set(inspection.conflicts);
|
|
const nonReplaceableSet = new Set(inspection.nonReplaceable);
|
|
const uploaded: string[] = [];
|
|
const skipped: string[] = [];
|
|
const errors: Array<{ name: string; error: string }> = [];
|
|
|
|
for (const file of files) {
|
|
const destination = path.join(directory, file.name);
|
|
if (conflictSet.has(file.name) && strategy === "skip") {
|
|
skipped.push(file.name);
|
|
continue;
|
|
}
|
|
if (conflictSet.has(file.name) && nonReplaceableSet.has(file.name)) {
|
|
errors.push({ name: file.name, error: "Cannot replace a directory or symbolic link" });
|
|
continue;
|
|
}
|
|
|
|
let bytes: Buffer;
|
|
try {
|
|
bytes = Buffer.from(await file.arrayBuffer());
|
|
} catch (error) {
|
|
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
|
continue;
|
|
}
|
|
|
|
if (conflictSet.has(file.name)) {
|
|
try {
|
|
fs.unlinkSync(destination);
|
|
} catch (error) {
|
|
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
try {
|
|
fs.writeFileSync(destination, bytes, { flag: "wx" });
|
|
uploaded.push(file.name);
|
|
} catch (error) {
|
|
errors.push({ name: file.name, error: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ uploaded, skipped, errors },
|
|
{ status: errors.length > 0 ? 207 : 200 },
|
|
);
|
|
} catch (error) {
|
|
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
function createFileBodyStream(filePath: string, range?: { start: number; end: number }): ReadableStream<Uint8Array> {
|
|
const fileStream = fs.createReadStream(filePath, range);
|
|
let closed = false;
|
|
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
fileStream.on("data", (chunk: Buffer) => {
|
|
if (closed) return;
|
|
try {
|
|
controller.enqueue(new Uint8Array(chunk));
|
|
} catch {
|
|
closed = true;
|
|
fileStream.destroy();
|
|
}
|
|
});
|
|
fileStream.once("end", () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
// The browser may cancel media probes before the file stream ends.
|
|
}
|
|
});
|
|
fileStream.once("error", (error) => {
|
|
if (closed) return;
|
|
closed = true;
|
|
try {
|
|
controller.error(error);
|
|
} catch {
|
|
// The response was already abandoned by the client.
|
|
}
|
|
});
|
|
},
|
|
cancel() {
|
|
closed = true;
|
|
fileStream.destroy();
|
|
},
|
|
});
|
|
}
|
|
|
|
function encodeHeaderValue(value: string): string {
|
|
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) =>
|
|
`%${ch.charCodeAt(0).toString(16).toUpperCase()}`
|
|
);
|
|
}
|
|
|
|
function getContentDisposition(filePath: string, asDownload = false): string {
|
|
const disposition = asDownload ? "attachment" : "inline";
|
|
const fileName = path.basename(filePath);
|
|
const fallback = fileName.replace(/[^\x20-\x7E]|["\\;\r\n]/g, "_") || "download";
|
|
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encodeHeaderValue(fileName)}`;
|
|
}
|
|
|
|
function streamFile(filePath: string, stat: fs.Stats, contentType: string, rangeHeader: string | null, asDownload = false): Response {
|
|
const headers = {
|
|
"Content-Type": contentType,
|
|
"Cache-Control": "no-cache",
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Disposition": getContentDisposition(filePath, asDownload),
|
|
};
|
|
|
|
if (!rangeHeader) {
|
|
return new Response(createFileBodyStream(filePath), {
|
|
headers: {
|
|
...headers,
|
|
"Content-Length": String(stat.size),
|
|
},
|
|
});
|
|
}
|
|
|
|
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader);
|
|
if (!match) {
|
|
return new Response(null, {
|
|
status: 416,
|
|
headers: {
|
|
...headers,
|
|
"Content-Range": `bytes */${stat.size}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
let start = match[1] ? Number(match[1]) : 0;
|
|
let end = match[2] ? Number(match[2]) : stat.size - 1;
|
|
if (!match[1] && match[2]) {
|
|
const suffixLength = Number(match[2]);
|
|
start = Math.max(stat.size - suffixLength, 0);
|
|
end = stat.size - 1;
|
|
}
|
|
|
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= stat.size) {
|
|
return new Response(null, {
|
|
status: 416,
|
|
headers: {
|
|
...headers,
|
|
"Content-Range": `bytes */${stat.size}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
end = Math.min(end, stat.size - 1);
|
|
const chunkSize = end - start + 1;
|
|
return new Response(createFileBodyStream(filePath, { start, end }), {
|
|
status: 206,
|
|
headers: {
|
|
...headers,
|
|
"Content-Length": String(chunkSize),
|
|
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function wrapDocxPreviewHtml(bodyHtml: string, fileName: string): string {
|
|
return `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
:root { color-scheme: light; }
|
|
html, body { margin: 0; min-height: 100%; background: #eef1f5; color: #171717; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 28px; }
|
|
main {
|
|
box-sizing: border-box;
|
|
max-width: 840px;
|
|
min-height: calc(100vh - 56px);
|
|
margin: 0 auto;
|
|
padding: 56px 64px;
|
|
background: #fff;
|
|
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.14);
|
|
}
|
|
.file-title {
|
|
margin: 0 0 28px;
|
|
padding-bottom: 10px;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
color: #6b7280;
|
|
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
word-break: break-word;
|
|
}
|
|
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 1.1em 0 0.45em; color: #111827; }
|
|
p { margin: 0.65em 0; line-height: 1.7; }
|
|
table { border-collapse: collapse; max-width: 100%; margin: 1em 0; }
|
|
th, td { border: 1px solid #d1d5db; padding: 6px 9px; vertical-align: top; }
|
|
img { max-width: 100%; height: auto; }
|
|
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
a { color: #2563eb; }
|
|
@media (max-width: 720px) {
|
|
body { padding: 0; background: #fff; }
|
|
main { min-height: 100vh; padding: 28px 22px; box-shadow: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<div class="file-title">${escapeHtml(fileName)}</div>
|
|
${bodyHtml}
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ path: string[] }> }
|
|
) {
|
|
try {
|
|
const { path: segments } = await params;
|
|
const filePath = filePathFromSegments(segments);
|
|
const rawType = request.nextUrl.searchParams.get("type") ?? "list";
|
|
const type = parseFileRequestType(rawType);
|
|
if (!type) {
|
|
return NextResponse.json({ error: "Invalid file request type" }, { status: 400 });
|
|
}
|
|
const sessionId = request.nextUrl.searchParams.get("sessionId");
|
|
|
|
const allowedRoots = await getAllowedFileRoots();
|
|
const allowedByRoot = isFilePathAllowed(filePath, allowedRoots);
|
|
const allowedBySessionReference =
|
|
!allowedByRoot &&
|
|
type !== "list" &&
|
|
await isFilePathReferencedBySession(filePath, sessionId);
|
|
if (!allowedByRoot && !allowedBySessionReference) {
|
|
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
|
|
let stat: fs.Stats;
|
|
try {
|
|
stat = fs.statSync(filePath);
|
|
} catch {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
if (!allowedBySessionReference && !isExistingFilePathAllowed(filePath, allowedRoots)) {
|
|
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
|
}
|
|
|
|
if (type === "read") {
|
|
if (!stat.isFile()) {
|
|
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
|
}
|
|
const imageMime = getImageMime(filePath);
|
|
if (imageMime) {
|
|
if (stat.size > IMAGE_PREVIEW_MAX_BYTES) {
|
|
return NextResponse.json({ error: "Image too large (>10MB)" }, { status: 413 });
|
|
}
|
|
return streamFile(filePath, stat, imageMime, request.headers.get("range"));
|
|
}
|
|
const audioMime = getAudioMime(filePath);
|
|
if (audioMime) {
|
|
return streamFile(filePath, stat, audioMime, request.headers.get("range"));
|
|
}
|
|
const documentMime = getDocumentMime(filePath);
|
|
if (documentMime) {
|
|
return streamFile(filePath, stat, documentMime, request.headers.get("range"));
|
|
}
|
|
if (stat.size > TEXT_PREVIEW_MAX_BYTES) {
|
|
return NextResponse.json({ error: "File too large for preview (>256KB)" }, { status: 413 });
|
|
}
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
const language = getLanguage(filePath);
|
|
return NextResponse.json({ content, language, size: stat.size });
|
|
}
|
|
|
|
if (type === "download") {
|
|
if (!stat.isFile()) {
|
|
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
|
}
|
|
const mime = getImageMime(filePath) || getAudioMime(filePath) || getDocumentMime(filePath) || "application/octet-stream";
|
|
return streamFile(filePath, stat, mime, request.headers.get("range"), true);
|
|
}
|
|
|
|
if (type === "meta") {
|
|
if (!stat.isFile()) {
|
|
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
|
}
|
|
const imageMime = getImageMime(filePath);
|
|
const audioMime = getAudioMime(filePath);
|
|
const documentMime = getDocumentMime(filePath);
|
|
return NextResponse.json({
|
|
size: stat.size,
|
|
language: getLanguage(filePath),
|
|
mime: imageMime || audioMime || documentMime || "text/plain",
|
|
previewKind: documentPreviewKind(filePath),
|
|
});
|
|
}
|
|
|
|
if (type === "preview") {
|
|
if (!stat.isFile()) {
|
|
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
|
}
|
|
if (getFileExt(filePath) !== "docx") {
|
|
return NextResponse.json({ error: "Preview not available for this file type" }, { status: 400 });
|
|
}
|
|
if (stat.size > DOCX_PREVIEW_MAX_BYTES) {
|
|
return NextResponse.json({ error: "DOCX too large for preview (>10MB)" }, { status: 413 });
|
|
}
|
|
|
|
const mammoth = await import("mammoth");
|
|
const result = await mammoth.convertToHtml(
|
|
{ path: filePath },
|
|
{
|
|
externalFileAccess: false,
|
|
convertImage: mammoth.images.dataUri,
|
|
}
|
|
);
|
|
const html = wrapDocxPreviewHtml(result.value, path.basename(filePath));
|
|
return new Response(html, {
|
|
headers: {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
"Cache-Control": "no-cache",
|
|
"Content-Security-Policy": "default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
|
|
"Referrer-Policy": "no-referrer",
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
});
|
|
}
|
|
|
|
if (type === "watch") {
|
|
if (!stat.isFile()) {
|
|
return NextResponse.json({ error: "Not a file" }, { status: 400 });
|
|
}
|
|
let watcher: fs.FSWatcher | null = null;
|
|
let lastMtimeMs = stat.mtimeMs;
|
|
let lastSize = stat.size;
|
|
const stream = new ReadableStream({
|
|
start(controller) {
|
|
const send = (eventName: string, data: Record<string, unknown>) => {
|
|
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
try {
|
|
controller.enqueue(new TextEncoder().encode(payload));
|
|
} catch {
|
|
// client disconnected
|
|
}
|
|
};
|
|
// Send initial ping so client knows connection is live
|
|
send("connected", { filePath });
|
|
try {
|
|
watcher = fs.watch(filePath, () => {
|
|
try {
|
|
const s = fs.statSync(filePath);
|
|
// Some platforms emit watch events for file reads/attribute
|
|
// access. Ignore those or the client's refresh read loops.
|
|
if (s.mtimeMs === lastMtimeMs && s.size === lastSize) return;
|
|
lastMtimeMs = s.mtimeMs;
|
|
lastSize = s.size;
|
|
send("change", { mtime: s.mtime.toISOString(), size: s.size });
|
|
} catch {
|
|
send("change", { mtime: new Date().toISOString(), size: 0 });
|
|
}
|
|
});
|
|
watcher.on("error", () => {
|
|
try { controller.close(); } catch { /* ignore */ }
|
|
});
|
|
} catch {
|
|
send("error", { message: "Failed to watch file" });
|
|
controller.close();
|
|
}
|
|
},
|
|
cancel() {
|
|
try { watcher?.close(); } catch { /* ignore */ }
|
|
},
|
|
});
|
|
return new Response(stream, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
});
|
|
}
|
|
|
|
// type === "list"
|
|
if (!stat.isDirectory()) {
|
|
return NextResponse.json({ error: "Not a directory" }, { status: 400 });
|
|
}
|
|
|
|
// Avoid per-entry stat calls for normal files and directories. Symlinks and
|
|
// filesystems without directory type information use the stat fallback.
|
|
const dirents = fs.readdirSync(filePath, { withFileTypes: true });
|
|
const entries = dirents
|
|
.filter((d) => !IGNORED_NAMES.has(d.name) && !IGNORED_SUFFIXES.some((s) => d.name.endsWith(s)))
|
|
.flatMap((d) => {
|
|
const isDir = resolveDirentIsDirectory(d, path.join(filePath, d.name));
|
|
return isDir === null
|
|
? []
|
|
: [{ name: d.name, isDir, size: 0, modified: "" }];
|
|
})
|
|
.sort((a, b) => {
|
|
// Dirs first, then files, both alphabetically
|
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
return NextResponse.json({ entries, path: filePath });
|
|
} catch (error) {
|
|
return NextResponse.json({ error: String(error) }, { status: 500 });
|
|
}
|
|
}
|