diff --git a/ui/src/app/api/files/delete/route.ts b/ui/src/app/api/files/delete/route.ts new file mode 100644 index 00000000..55a21efd --- /dev/null +++ b/ui/src/app/api/files/delete/route.ts @@ -0,0 +1,56 @@ +/* eslint-disable */ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import { getDatasetsRoot, getTrainingFolder } from '@/server/settings'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { filePath } = body; + + if (!filePath || typeof filePath !== 'string') { + return new NextResponse('filePath is required', { status: 400 }); + } + + // Decode the path + const decodedFilePath = decodeURIComponent(filePath); + + // Get allowed directories + const datasetRoot = await getDatasetsRoot(); + const trainingRoot = await getTrainingFolder(); + const allowedDirs = [datasetRoot, trainingRoot]; + + // Security check: resolve so `..` segments collapse, then verify still under + // an allowed root. Substring `.includes('..')` false-positives on filenames + // containing `..` as text (e.g. an ellipsis in a filename). + const resolvedFilePath = path.resolve(decodedFilePath); + const isAllowed = allowedDirs.some( + allowedDir => resolvedFilePath === allowedDir || resolvedFilePath.startsWith(allowedDir + path.sep), + ); + + if (!isAllowed) { + console.warn(`Access denied: ${resolvedFilePath} not in ${allowedDirs.join(', ')}`); + return new NextResponse('Access denied', { status: 403 }); + } + + // Check if file exists + if (!fs.existsSync(resolvedFilePath)) { + console.warn(`File not found: ${resolvedFilePath}`); + return new NextResponse('File not found', { status: 404 }); + } + + // Get file info + const stat = fs.statSync(resolvedFilePath); + if (!stat.isFile()) { + return new NextResponse('Not a file', { status: 400 }); + } + + fs.unlinkSync(resolvedFilePath); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting file:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} diff --git a/ui/src/components/FilesWidget.tsx b/ui/src/components/FilesWidget.tsx index 5c5d6632..f6b9c220 100644 --- a/ui/src/components/FilesWidget.tsx +++ b/ui/src/components/FilesWidget.tsx @@ -1,9 +1,10 @@ import React from 'react'; import useFilesList from '@/hooks/useFilesList'; -import Link from 'next/link'; -import { Loader2, AlertCircle, Download, Box, Brain } from 'lucide-react'; +import { Loader2, AlertCircle, Download, Box, Brain, Trash2 } from 'lucide-react'; import { openMergeLoRAsModal } from './MergeLoRAsModal'; import { getFilename, getFoldername } from '@/utils/basic'; +import { openConfirm } from './ConfirmModal'; +import { apiClient } from '@/utils/api'; export default function FilesWidget({ jobID, jobName }: { jobID: string; jobName: string }) { const { files, status, refreshFiles } = useFilesList(jobID, 5000); @@ -20,6 +21,26 @@ export default function FilesWidget({ jobID, jobName }: { jobID: string; jobName } }; + const handleDeleteFile = (filePath: string) => { + const fileName = getFilename(filePath); + openConfirm({ + title: 'Delete Checkpoint', + message: `Are you sure you want to delete "${fileName}"? This action cannot be undone.`, + type: 'warning', + confirmText: 'Delete', + onConfirm: () => { + apiClient + .post('/api/files/delete', { filePath }) + .then(() => { + refreshFiles(); + }) + .catch(error => { + console.error('Error deleting checkpoint:', error); + }); + }, + }); + }; + return (