From 2e02765e4b79d0d145520f9005c75d382805dc2e Mon Sep 17 00:00:00 2001 From: diogo464 Date: Mon, 11 Aug 2025 16:28:59 +0100 Subject: implement RESTful API and remove legacy endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created unified /api/fs/[...path] endpoint with full REST methods: - GET: List directory contents or file info - POST: Create directories using Drive_mkdir() - PUT: Upload files with multipart form data - DELETE: Remove files/directories using Drive_remove() - Added /api/fs route for root directory listing - Added Drive_mkdir() function to drive_server.ts using fctdrive mkdir command - Removed legacy /api/delete and /api/upload endpoints - Updated CLAUDE.md with comprehensive API documentation and examples - All endpoints support authentication with AUTH: 1 header in development - Proper error handling, file size validation, and cache revalidation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 26 +++- frontend/app/api/delete/route.ts | 48 -------- frontend/app/api/fs/[...path]/route.ts | 209 +++++++++++++++++++++++++++++++++ frontend/app/api/fs/route.ts | 19 +++ frontend/app/api/upload/route.ts | 127 -------------------- frontend/lib/drive_server.ts | 11 ++ 6 files changed, 264 insertions(+), 176 deletions(-) delete mode 100644 frontend/app/api/delete/route.ts create mode 100644 frontend/app/api/fs/[...path]/route.ts create mode 100644 frontend/app/api/fs/route.ts delete mode 100644 frontend/app/api/upload/route.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3468c48..8fe6152 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,30 @@ - **Updates**: Manual refresh only (no real-time) - the server is running at `127.0.0.1:3000` and you can use curl to test the page +## API Endpoints + +### Legacy Endpoints +- `/api/list` - GET all files recursively from root + +### RESTful API - `/api/fs/[...path]` +- **GET** `/api/fs/path/to/directory` - List directory contents +- **POST** `/api/fs/path/to/directory` - Create directory +- **PUT** `/api/fs/path/to/file.txt` - Upload/create file +- **DELETE** `/api/fs/path/to/item` - Delete file or directory + ## API Testing - For testing authenticated endpoints in development, use header `AUTH: 1` -- Example: `curl -H "AUTH: 1" -H "Content-Type: application/json" -X POST localhost:3000/api/delete -d '{"path":"/file.txt"}'` \ No newline at end of file +- Examples: + ```bash + # List directory + curl "http://localhost:3000/api/fs/some/directory" + + # Create directory + curl -X POST "http://localhost:3000/api/fs/new_directory" -H "AUTH: 1" + + # Upload file + curl -X PUT "http://localhost:3000/api/fs/path/file.txt" -H "AUTH: 1" -H "Content-Type: multipart/form-data" -F "file=@local_file.txt" + + # Delete file/directory + curl -X DELETE "http://localhost:3000/api/fs/path/to/delete" -H "AUTH: 1" + ``` \ No newline at end of file diff --git a/frontend/app/api/delete/route.ts b/frontend/app/api/delete/route.ts deleted file mode 100644 index b4a27d4..0000000 --- a/frontend/app/api/delete/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { Auth_get_user, Auth_user_can_upload } from '@/lib/auth' -import { Drive_remove } from '@/lib/drive_server' -import { revalidatePath } from 'next/cache' - -export async function POST(request: NextRequest) { - try { - // Check user authentication and permissions - const user = await Auth_get_user() - if (!user.isLoggedIn) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - if (!Auth_user_can_upload(user)) { - return NextResponse.json({ error: 'User does not have upload permissions' }, { status: 403 }) - } - - // Parse JSON body - const body = await request.json() - const path = body.path - - // Validate path - if (!path || typeof path !== 'string') { - return NextResponse.json({ error: 'Path is required and must be a string' }, { status: 400 }) - } - - // Remove file/directory using Drive_remove - await Drive_remove(path, user.email) - - // Revalidate the parent directory to refresh listings - const parentPath = path.split('/').slice(0, -1).join('/') || '/' - revalidatePath(`/drive${parentPath}`) - revalidatePath('/drive') - - return NextResponse.json({ - success: true, - message: 'Path deleted successfully', - deletedPath: path - }) - - } catch (error) { - console.error('Delete error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/frontend/app/api/fs/[...path]/route.ts b/frontend/app/api/fs/[...path]/route.ts new file mode 100644 index 0000000..3a299af --- /dev/null +++ b/frontend/app/api/fs/[...path]/route.ts @@ -0,0 +1,209 @@ +import { NextRequest, NextResponse } from 'next/server' +import { writeFile, unlink } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { randomUUID } from 'crypto' +import { Auth_get_user, Auth_user_can_upload } from '@/lib/auth' +import { Drive_ls, Drive_remove, Drive_mkdir, Drive_import } from '@/lib/drive_server' +import { UPLOAD_MAX_FILE_SIZE } from '@/lib/constants' +import { revalidatePath } from 'next/cache' + +// GET /api/fs/path/to/file - Get file/directory listing +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + const { path: pathSegments } = await params + const filePath = '/' + (pathSegments?.join('/') || '') + + // Get directory listing using Drive_ls (non-recursive) + const entries = await Drive_ls(filePath, false) + + return NextResponse.json(entries) + + } catch (error) { + console.error('GET fs error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} + +// DELETE /api/fs/path/to/file - Delete file/directory +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + // Check user authentication and permissions + const user = await Auth_get_user() + if (!user.isLoggedIn) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + if (!Auth_user_can_upload(user)) { + return NextResponse.json({ error: 'User does not have upload permissions' }, { status: 403 }) + } + + const { path: pathSegments } = await params + const filePath = '/' + (pathSegments?.join('/') || '') + + // Remove file/directory using Drive_remove + await Drive_remove(filePath, user.email) + + // Revalidate the parent directory to refresh listings + const parentPath = filePath.split('/').slice(0, -1).join('/') || '/' + revalidatePath(`/drive${parentPath}`) + revalidatePath('/drive') + + return NextResponse.json({ + success: true, + message: 'Path deleted successfully', + deletedPath: filePath + }) + + } catch (error) { + console.error('DELETE fs error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} + +// PUT /api/fs/path/to/file - Create/upload file +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + // Check user authentication and permissions + const user = await Auth_get_user() + if (!user.isLoggedIn) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + if (!Auth_user_can_upload(user)) { + return NextResponse.json({ error: 'User does not have upload permissions' }, { status: 403 }) + } + + const { path: pathSegments } = await params + const filePath = '/' + (pathSegments?.join('/') || '') + + // Check if request has file content + const contentType = request.headers.get('content-type') + if (!contentType || (!contentType.includes('multipart/form-data') && !contentType.includes('application/octet-stream'))) { + return NextResponse.json({ + error: 'Content-Type must be multipart/form-data or application/octet-stream' + }, { status: 400 }) + } + + let fileBuffer: Buffer + let filename: string + + if (contentType.includes('multipart/form-data')) { + // Handle multipart form data + const formData = await request.formData() + const file = formData.get('file') as File + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } + + if (file.size > UPLOAD_MAX_FILE_SIZE) { + return NextResponse.json({ + error: `File exceeds maximum size of ${UPLOAD_MAX_FILE_SIZE / (1024 * 1024)}MB` + }, { status: 400 }) + } + + const bytes = await file.arrayBuffer() + fileBuffer = Buffer.from(bytes) + filename = file.name + } else { + // Handle raw binary data + const bytes = await request.arrayBuffer() + fileBuffer = Buffer.from(bytes) + + if (fileBuffer.length > UPLOAD_MAX_FILE_SIZE) { + return NextResponse.json({ + error: `File exceeds maximum size of ${UPLOAD_MAX_FILE_SIZE / (1024 * 1024)}MB` + }, { status: 400 }) + } + + // Extract filename from path + filename = pathSegments?.[pathSegments.length - 1] || 'upload' + } + + // Create temporary file + const tempFileName = `${randomUUID()}-${filename}` + const tempFilePath = join(tmpdir(), tempFileName) + + // Save file to temporary location + await writeFile(tempFilePath, fileBuffer) + + // Import file using Drive_import (uses --mode move, so temp file is already deleted) + await Drive_import(tempFilePath, filePath, user.email) + + // Revalidate the parent directory to refresh listings + const parentPath = filePath.split('/').slice(0, -1).join('/') || '/' + revalidatePath(`/drive${parentPath}`) + revalidatePath('/drive') + + return NextResponse.json({ + success: true, + message: 'File uploaded successfully', + path: filePath + }) + + } catch (error) { + console.error('PUT fs error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} + +// POST /api/fs/path/to/directory - Create directory +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + try { + // Check user authentication and permissions + const user = await Auth_get_user() + if (!user.isLoggedIn) { + return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) + } + + if (!Auth_user_can_upload(user)) { + return NextResponse.json({ error: 'User does not have upload permissions' }, { status: 403 }) + } + + const { path: pathSegments } = await params + const dirPath = '/' + (pathSegments?.join('/') || '') + + // Create directory using Drive_mkdir + await Drive_mkdir(dirPath, user.email) + + // Revalidate the parent directory to refresh listings + const parentPath = dirPath.split('/').slice(0, -1).join('/') || '/' + revalidatePath(`/drive${parentPath}`) + revalidatePath('/drive') + + return NextResponse.json({ + success: true, + message: 'Directory created successfully', + path: dirPath + }) + + } catch (error) { + console.error('POST fs error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/api/fs/route.ts b/frontend/app/api/fs/route.ts new file mode 100644 index 0000000..61d0f8a --- /dev/null +++ b/frontend/app/api/fs/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server' +import { Drive_ls } from '@/lib/drive_server' + +// GET /api/fs - Get root directory listing +export async function GET() { + try { + // Get root directory listing using Drive_ls (non-recursive) + const entries = await Drive_ls('/', false) + + return NextResponse.json(entries) + + } catch (error) { + console.error('GET fs root error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/frontend/app/api/upload/route.ts b/frontend/app/api/upload/route.ts deleted file mode 100644 index 164d86d..0000000 --- a/frontend/app/api/upload/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { writeFile, unlink } from 'fs/promises' -import { tmpdir } from 'os' -import { join } from 'path' -import { randomUUID } from 'crypto' -import { Auth_get_user, Auth_user_can_upload } from '@/lib/auth' -import { Drive_import } from '@/lib/drive_server' -import { UPLOAD_MAX_FILE_SIZE, UPLOAD_MAX_FILES } from '@/lib/constants' -import { revalidatePath } from 'next/cache' - -export async function POST(request: NextRequest) { - try { - // Check user authentication and permissions - const user = await Auth_get_user() - if (!user.isLoggedIn) { - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - if (!Auth_user_can_upload(user)) { - return NextResponse.json({ error: 'User does not have upload permissions' }, { status: 403 }) - } - - // Parse form data - const formData = await request.formData() - const files = formData.getAll('files') as File[] - const targetPath = formData.get('targetPath') as string || '' - - // Validate files - if (!files || files.length === 0) { - return NextResponse.json({ error: 'No files provided' }, { status: 400 }) - } - - if (files.length > UPLOAD_MAX_FILES) { - return NextResponse.json({ - error: `Too many files. Maximum ${UPLOAD_MAX_FILES} files allowed` - }, { status: 400 }) - } - - // Validate each file - for (const file of files) { - if (file.size > UPLOAD_MAX_FILE_SIZE) { - return NextResponse.json({ - error: `File '${file.name}' exceeds maximum size of ${UPLOAD_MAX_FILE_SIZE / (1024 * 1024)}MB` - }, { status: 400 }) - } - } - - const uploadResults = [] - const tempFiles: string[] = [] - - try { - // Process each file - for (const file of files) { - // Create temporary file - const tempFileName = `${randomUUID()}-${file.name}` - const tempFilePath = join(tmpdir(), tempFileName) - tempFiles.push(tempFilePath) - - // Save file to temporary location - const bytes = await file.arrayBuffer() - const buffer = Buffer.from(bytes) - await writeFile(tempFilePath, buffer) - - // Determine target drive path - const driveFilePath = targetPath ? `${targetPath}/${file.name}` : `/${file.name}` - - try { - // Import file using Drive_import - await Drive_import(tempFilePath, driveFilePath, user.email) - uploadResults.push({ - filename: file.name, - success: true, - message: 'File uploaded successfully' - }) - } catch (error) { - console.error(`Failed to import file ${file.name}:`, error) - uploadResults.push({ - filename: file.name, - success: false, - message: error instanceof Error ? error.message : 'Unknown error during import' - }) - } - } - - // Clean up temporary files - for (const tempFile of tempFiles) { - try { - await unlink(tempFile) - } catch (error) { - console.error(`Failed to delete temp file ${tempFile}:`, error) - } - } - - // Revalidate the target path to refresh the directory listing - revalidatePath(`/drive${targetPath}`) - revalidatePath('/drive') - - // Check if any uploads succeeded - const successfulUploads = uploadResults.filter(result => result.success) - const failedUploads = uploadResults.filter(result => !result.success) - - return NextResponse.json({ - success: true, - message: `${successfulUploads.length} files uploaded successfully${failedUploads.length > 0 ? `, ${failedUploads.length} failed` : ''}`, - results: uploadResults - }) - - } catch (error) { - // Clean up temporary files on error - for (const tempFile of tempFiles) { - try { - await unlink(tempFile) - } catch (cleanupError) { - console.error(`Failed to delete temp file during cleanup ${tempFile}:`, cleanupError) - } - } - throw error - } - - } catch (error) { - console.error('Upload error:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Internal server error' }, - { status: 500 } - ) - } -} \ No newline at end of file diff --git a/frontend/lib/drive_server.ts b/frontend/lib/drive_server.ts index eba2d5a..2f80002 100644 --- a/frontend/lib/drive_server.ts +++ b/frontend/lib/drive_server.ts @@ -83,4 +83,15 @@ export async function Drive_remove(path: string, email: string) { if (result.status !== 0) { throw new Error(`fctdrive exited with code ${result.status}: ${result.stderr}`); } +} + +/// creates a directory at the given path +export async function Drive_mkdir(path: string, email: string) { + const result = spawnSync('fctdrive', ['mkdir', '--email', email, '--path', path], { encoding: 'utf-8' }); + if (result.error) { + throw new Error(`Failed to execute fctdrive: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(`fctdrive exited with code ${result.status}: ${result.stderr}`); + } } \ No newline at end of file -- cgit