mirror of
				https://github.com/open-webui/openapi-servers
				synced 2025-06-26 18:17:04 +00:00 
			
		
		
		
	Merge pull request #23 from taylorwilsdon/enhance_filesystem
This commit is contained in:
		
						commit
						5239cdf4c1
					
				| @ -9,7 +9,8 @@ import pathlib | |||||||
| import asyncio | import asyncio | ||||||
| from typing import List, Optional, Literal | from typing import List, Optional, Literal | ||||||
| import difflib | import difflib | ||||||
| 
 | import shutil | ||||||
|  | from datetime import datetime, timezone | ||||||
| app = FastAPI( | app = FastAPI( | ||||||
|     title="Secure Filesystem API", |     title="Secure Filesystem API", | ||||||
|     version="0.1.0", |     version="0.1.0", | ||||||
| @ -29,7 +30,7 @@ app.add_middleware( | |||||||
| 
 | 
 | ||||||
| # Constants | # Constants | ||||||
| ALLOWED_DIRECTORIES = [ | ALLOWED_DIRECTORIES = [ | ||||||
|     str(pathlib.Path(os.path.expanduser("~/mydir")).resolve()) |     str(pathlib.Path(os.path.expanduser("~/")).resolve()) | ||||||
| ]  # 👈 Replace with your paths | ]  # 👈 Replace with your paths | ||||||
| 
 | 
 | ||||||
| # ------------------------------------------------------------------------------ | # ------------------------------------------------------------------------------ | ||||||
| @ -40,11 +41,16 @@ ALLOWED_DIRECTORIES = [ | |||||||
| def normalize_path(requested_path: str) -> pathlib.Path: | def normalize_path(requested_path: str) -> pathlib.Path: | ||||||
|     requested = pathlib.Path(os.path.expanduser(requested_path)).resolve() |     requested = pathlib.Path(os.path.expanduser(requested_path)).resolve() | ||||||
|     for allowed in ALLOWED_DIRECTORIES: |     for allowed in ALLOWED_DIRECTORIES: | ||||||
|         if str(requested).startswith(allowed): |         if str(requested).lower().startswith(allowed.lower()): # Case-insensitive check | ||||||
|             return requested |             return requested | ||||||
|     raise HTTPException( |     raise HTTPException( | ||||||
|         status_code=403, |         status_code=403, | ||||||
|         detail=f"Access denied: {requested} is outside allowed directories.", |         detail={ | ||||||
|  |             "error": "Access Denied", | ||||||
|  |             "requested_path": str(requested), | ||||||
|  |             "message": "Requested path is outside allowed directories.", | ||||||
|  |             "allowed_directories": ALLOWED_DIRECTORIES, | ||||||
|  |         }, | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -106,85 +112,157 @@ class SearchFilesRequest(BaseModel): | |||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | class SearchContentRequest(BaseModel): | ||||||
|  |     path: str = Field(..., description="Base directory to search within.") | ||||||
|  |     search_query: str = Field(..., description="Text content to search for (case-insensitive).") | ||||||
|  |     recursive: bool = Field( | ||||||
|  |         default=True, description="Whether to search recursively in subdirectories." | ||||||
|  |     ) | ||||||
|  |     file_pattern: Optional[str] = Field( | ||||||
|  |         default="*", description="Glob pattern to filter files to search within (e.g., '*.py')." | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class DeletePathRequest(BaseModel): | ||||||
|  |     path: str = Field(..., description="Path to the file or directory to delete.") | ||||||
|  |     recursive: bool = Field( | ||||||
|  |         default=False, description="If true and path is a directory, delete recursively. Required if directory is not empty." | ||||||
|  |     ) | ||||||
|  |     confirm_delete: bool = Field( | ||||||
|  |         ..., description="Must be explicitly set to true to confirm deletion." | ||||||
|  |     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class MovePathRequest(BaseModel): | ||||||
|  |     source_path: str = Field(..., description="The current path of the file or directory.") | ||||||
|  |     destination_path: str = Field(..., description="The new path for the file or directory.") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class GetMetadataRequest(BaseModel): | ||||||
|  |     path: str = Field(..., description="Path to the file or directory to get metadata for.") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| # ------------------------------------------------------------------------------ | # ------------------------------------------------------------------------------ | ||||||
| # Routes | # Routes | ||||||
| # ------------------------------------------------------------------------------ | # ------------------------------------------------------------------------------ | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.post("/read_file", response_class=PlainTextResponse, summary="Read a file") | class SuccessResponse(BaseModel): | ||||||
|  |     message: str = Field(..., description="Success message indicating the operation was completed.") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ReadFileResponse(BaseModel): | ||||||
|  |     content: str = Field(..., description="UTF-8 encoded text content of the file.") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class DiffResponse(BaseModel): | ||||||
|  |     diff: str = Field(..., description="Unified diff output comparing original and modified content.") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @app.post("/read_file", response_model=ReadFileResponse, summary="Read a file") # Changed response_class to response_model | ||||||
| async def read_file(data: ReadFileRequest = Body(...)): | async def read_file(data: ReadFileRequest = Body(...)): | ||||||
|     """ |     """ | ||||||
|     Read the entire contents of a file. |     Read the entire contents of a file and return as JSON. | ||||||
|     """ |     """ | ||||||
|     path = normalize_path(data.path) |     path = normalize_path(data.path) | ||||||
|     try: |     try: | ||||||
|         return path.read_text(encoding="utf-8") |         file_content = path.read_text(encoding="utf-8") | ||||||
|  |         return ReadFileResponse(content=file_content) # Return Pydantic model instance | ||||||
|  |     except FileNotFoundError: | ||||||
|  |         raise HTTPException(status_code=404, detail=f"File not found: {data.path}") | ||||||
|  |     except PermissionError: | ||||||
|  |          raise HTTPException(status_code=403, detail=f"Permission denied for file: {data.path}") | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         raise HTTPException(status_code=400, detail=str(e)) |         # More specific error for generic read issues | ||||||
|  |         raise HTTPException(status_code=500, detail=f"Failed to read file {data.path}: {str(e)}") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.post("/write_file", response_class=PlainTextResponse, summary="Write to a file") | @app.post("/write_file", response_model=SuccessResponse, summary="Write to a file") | ||||||
| async def write_file(data: WriteFileRequest = Body(...)): | async def write_file(data: WriteFileRequest = Body(...)): | ||||||
|     """ |     """ | ||||||
|     Write content to a file, overwriting if it exists. |     Write content to a file, overwriting if it exists. Returns JSON success message. | ||||||
|     """ |     """ | ||||||
|     path = normalize_path(data.path) |     path = normalize_path(data.path) | ||||||
|     try: |     try: | ||||||
|         path.write_text(data.content, encoding="utf-8") |         path.write_text(data.content, encoding="utf-8") | ||||||
|         return f"Successfully wrote to {data.path}" |         return SuccessResponse(message=f"Successfully wrote to {data.path}") | ||||||
|  |     except PermissionError: | ||||||
|  |         raise HTTPException(status_code=403, detail=f"Permission denied to write to {data.path}") | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         raise HTTPException(status_code=400, detail=str(e)) |         raise HTTPException(status_code=500, detail=f"Failed to write to {data.path}: {str(e)}") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | from typing import Union # Add this import at the top with other typing imports | ||||||
|  | 
 | ||||||
| @app.post( | @app.post( | ||||||
|     "/edit_file", response_class=PlainTextResponse, summary="Edit a file with diff" |     "/edit_file", | ||||||
|  |     response_model=Union[SuccessResponse, DiffResponse], # Use Union for multiple response types | ||||||
|  |     summary="Edit a file with diff" | ||||||
| ) | ) | ||||||
| async def edit_file(data: EditFileRequest = Body(...)): | async def edit_file(data: EditFileRequest = Body(...)): | ||||||
|     """ |     """ | ||||||
|     Apply a list of edits to a text file. Support dry-run to get unified diff. |     Apply a list of edits to a text file. | ||||||
|  |     Returns JSON success message or JSON diff on dry-run. | ||||||
|     """ |     """ | ||||||
|     path = normalize_path(data.path) |     path = normalize_path(data.path) | ||||||
|     original = path.read_text(encoding="utf-8") |     try: | ||||||
|  |         original = path.read_text(encoding="utf-8") | ||||||
|  |     except FileNotFoundError: | ||||||
|  |         raise HTTPException(status_code=404, detail=f"File not found: {data.path}") | ||||||
|  |     except PermissionError: | ||||||
|  |         raise HTTPException(status_code=403, detail=f"Permission denied to read file: {data.path}") | ||||||
|  |     except Exception as e: | ||||||
|  |         raise HTTPException(status_code=500, detail=f"Failed to read file {data.path} for editing: {str(e)}") | ||||||
|  | 
 | ||||||
|     modified = original |     modified = original | ||||||
|  |     try: | ||||||
|  |         for edit in data.edits: | ||||||
|  |             if edit.oldText not in modified: | ||||||
|  |                 raise HTTPException( | ||||||
|  |                     status_code=400, | ||||||
|  |                     detail=f"Edit failed: oldText not found in content: '{edit.oldText[:50]}...'", | ||||||
|  |                 ) | ||||||
|  |             modified = modified.replace(edit.oldText, edit.newText, 1) | ||||||
| 
 | 
 | ||||||
|     for edit in data.edits: |         if data.dryRun: | ||||||
|         if edit.oldText not in modified: |             diff_output = difflib.unified_diff( | ||||||
|             raise HTTPException( |                 original.splitlines(keepends=True), | ||||||
|                 status_code=400, |                 modified.splitlines(keepends=True), | ||||||
|                 detail=f"oldText not found in content: {edit.oldText[:50]}", |                 fromfile=f"a/{data.path}", | ||||||
|  |                 tofile=f"b/{data.path}", | ||||||
|             ) |             ) | ||||||
|         modified = modified.replace(edit.oldText, edit.newText, 1) |             return DiffResponse(diff="".join(diff_output)) # Return JSON diff | ||||||
| 
 | 
 | ||||||
|     if data.dryRun: |         # Write changes if not dry run | ||||||
|         diff = difflib.unified_diff( |         path.write_text(modified, encoding="utf-8") | ||||||
|             original.splitlines(keepends=True), |         return SuccessResponse(message=f"Successfully edited file {data.path}") # Return JSON success | ||||||
|             modified.splitlines(keepends=True), |  | ||||||
|             fromfile="original", |  | ||||||
|             tofile="modified", |  | ||||||
|         ) |  | ||||||
|         return "".join(diff) |  | ||||||
| 
 | 
 | ||||||
|     path.write_text(modified, encoding="utf-8") |     except PermissionError: | ||||||
|     return f"Successfully edited file {data.path}" |         raise HTTPException(status_code=403, detail=f"Permission denied to write edited file: {data.path}") | ||||||
|  |     except Exception as e: | ||||||
|  |         # Catch errors during writing the modified file | ||||||
|  |         raise HTTPException(status_code=500, detail=f"Failed to write edited file {data.path}: {str(e)}") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.post( | @app.post( | ||||||
|     "/create_directory", response_class=PlainTextResponse, summary="Create a directory" |     "/create_directory", response_model=SuccessResponse, summary="Create a directory" | ||||||
| ) | ) | ||||||
| async def create_directory(data: CreateDirectoryRequest = Body(...)): | async def create_directory(data: CreateDirectoryRequest = Body(...)): | ||||||
|     """ |     """ | ||||||
|     Create a new directory recursively. |     Create a new directory recursively. Returns JSON success message. | ||||||
|     """ |     """ | ||||||
|     dir_path = normalize_path(data.path) |     dir_path = normalize_path(data.path) | ||||||
|     try: |     try: | ||||||
|         dir_path.mkdir(parents=True, exist_ok=True) |         dir_path.mkdir(parents=True, exist_ok=True) | ||||||
|         return f"Successfully created directory {data.path}" |         return SuccessResponse(message=f"Successfully created directory {data.path}") | ||||||
|  |     except PermissionError: | ||||||
|  |         raise HTTPException(status_code=403, detail=f"Permission denied to create directory {data.path}") | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         raise HTTPException(status_code=400, detail=str(e)) |         raise HTTPException(status_code=500, detail=f"Failed to create directory {data.path}: {str(e)}") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.post( | @app.post( | ||||||
|     "/list_directory", response_class=PlainTextResponse, summary="List a directory" |     "/list_directory", summary="List a directory" | ||||||
| ) | ) | ||||||
| async def list_directory(data: ListDirectoryRequest = Body(...)): | async def list_directory(data: ListDirectoryRequest = Body(...)): | ||||||
|     """ |     """ | ||||||
| @ -196,10 +274,11 @@ async def list_directory(data: ListDirectoryRequest = Body(...)): | |||||||
| 
 | 
 | ||||||
|     listing = [] |     listing = [] | ||||||
|     for entry in dir_path.iterdir(): |     for entry in dir_path.iterdir(): | ||||||
|         prefix = "[DIR]" if entry.is_dir() else "[FILE]" |         entry_type = "directory" if entry.is_dir() else "file" | ||||||
|         listing.append(f"{prefix} {entry.name}") |         listing.append({"name": entry.name, "type": entry_type}) | ||||||
| 
 | 
 | ||||||
|     return "\n".join(listing) |     # Return the list directly, FastAPI will serialize it to JSON | ||||||
|  |     return listing | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.post("/directory_tree", summary="Recursive directory tree") | @app.post("/directory_tree", summary="Recursive directory tree") | ||||||
| @ -251,6 +330,159 @@ async def search_files(data: SearchFilesRequest = Body(...)): | |||||||
|     return {"matches": results or ["No matches found"]} |     return {"matches": results or ["No matches found"]} | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @app.post("/delete_path", response_model=SuccessResponse, summary="Delete a file or directory") | ||||||
|  | async def delete_path(data: DeletePathRequest = Body(...)): | ||||||
|  |     """ | ||||||
|  |     Delete a specified file or directory. Requires explicit confirmation. | ||||||
|  |     Use 'recursive=True' to delete non-empty directories. | ||||||
|  |     Returns JSON success message. | ||||||
|  |     """ | ||||||
|  |     if not data.confirm_delete: | ||||||
|  |         raise HTTPException( | ||||||
|  |             status_code=400, | ||||||
|  |             detail="Deletion not confirmed. Set 'confirm_delete' to true to proceed." | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |     path = normalize_path(data.path) | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         if not path.exists(): | ||||||
|  |             raise HTTPException(status_code=404, detail=f"Path not found: {data.path}") | ||||||
|  | 
 | ||||||
|  |         if path.is_file(): | ||||||
|  |             path.unlink() # Raises FileNotFoundError if not exists, PermissionError if no permission | ||||||
|  |             return SuccessResponse(message=f"Successfully deleted file: {data.path}") | ||||||
|  |         elif path.is_dir(): | ||||||
|  |             if data.recursive: | ||||||
|  |                 shutil.rmtree(path) # Raises FileNotFoundError, PermissionError, NotADirectoryError | ||||||
|  |                 return SuccessResponse(message=f"Successfully deleted directory recursively: {data.path}") | ||||||
|  |             else: | ||||||
|  |                 try: | ||||||
|  |                     path.rmdir() # Raises FileNotFoundError, OSError (e.g., dir not empty, permission denied) | ||||||
|  |                     return SuccessResponse(message=f"Successfully deleted empty directory: {data.path}") | ||||||
|  |                 except OSError as e: | ||||||
|  |                     # Catch error if directory is not empty and recursive is false | ||||||
|  |                     raise HTTPException( | ||||||
|  |                         status_code=400, | ||||||
|  |                         detail=f"Directory not empty. Use 'recursive=True' to delete non-empty directories. Original error: {e}" | ||||||
|  |                     ) | ||||||
|  |         else: | ||||||
|  |              # Should not happen if exists() is true and it's not file/dir, but handle defensively | ||||||
|  |              raise HTTPException(status_code=400, detail=f"Path is not a file or directory: {data.path}") | ||||||
|  | 
 | ||||||
|  |     except PermissionError: | ||||||
|  |         raise HTTPException(status_code=403, detail=f"Permission denied to delete {data.path}") | ||||||
|  |     except Exception as e: | ||||||
|  |         # Catch other potential errors during deletion | ||||||
|  |         raise HTTPException(status_code=500, detail=f"Failed to delete {data.path}: {e}") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @app.post("/move_path", response_model=SuccessResponse, summary="Move or rename a file or directory") | ||||||
|  | async def move_path(data: MovePathRequest = Body(...)): | ||||||
|  |     """ | ||||||
|  |     Move or rename a file or directory from source_path to destination_path. | ||||||
|  |     Both paths must be within the allowed directories. | ||||||
|  |     Returns JSON success message. | ||||||
|  |     """ | ||||||
|  |     source = normalize_path(data.source_path) | ||||||
|  |     destination = normalize_path(data.destination_path) # Also normalize destination | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         if not source.exists(): | ||||||
|  |             raise HTTPException(status_code=404, detail=f"Source path not found: {data.source_path}") | ||||||
|  | 
 | ||||||
|  |         shutil.move(str(source), str(destination)) # Raises FileNotFoundError, PermissionError etc. | ||||||
|  |         return SuccessResponse(message=f"Successfully moved '{data.source_path}' to '{data.destination_path}'") | ||||||
|  | 
 | ||||||
|  |     except PermissionError: | ||||||
|  |         raise HTTPException(status_code=403, detail=f"Permission denied for move operation involving '{data.source_path}' or '{data.destination_path}'") | ||||||
|  |     except Exception as e: | ||||||
|  |         raise HTTPException(status_code=500, detail=f"Failed to move '{data.source_path}' to '{data.destination_path}': {e}") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @app.post("/get_metadata", summary="Get file or directory metadata") | ||||||
|  | async def get_metadata(data: GetMetadataRequest = Body(...)): | ||||||
|  |     """ | ||||||
|  |     Retrieve metadata for a specified file or directory path. | ||||||
|  |     """ | ||||||
|  |     path = normalize_path(data.path) | ||||||
|  | 
 | ||||||
|  |     try: | ||||||
|  |         if not path.exists(): | ||||||
|  |             raise HTTPException(status_code=404, detail=f"Path not found: {data.path}") | ||||||
|  | 
 | ||||||
|  |         stat_result = path.stat() | ||||||
|  | 
 | ||||||
|  |         # Determine type | ||||||
|  |         if path.is_file(): | ||||||
|  |             file_type = "file" | ||||||
|  |         elif path.is_dir(): | ||||||
|  |             file_type = "directory" | ||||||
|  |         else: | ||||||
|  |             file_type = "other" # Should generally not happen for existing paths normalized | ||||||
|  | 
 | ||||||
|  |         # Format timestamps (use UTC for consistency) | ||||||
|  |         mod_time = datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat() | ||||||
|  |         # Creation time (st_birthtime) is macOS/BSD specific, st_ctime is metadata change time on Linux | ||||||
|  |         # Use st_ctime as a fallback if st_birthtime isn't available | ||||||
|  |         try: | ||||||
|  |             create_time = datetime.fromtimestamp(stat_result.st_birthtime, tz=timezone.utc).isoformat() | ||||||
|  |         except AttributeError: | ||||||
|  |             create_time = datetime.fromtimestamp(stat_result.st_ctime, tz=timezone.utc).isoformat() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |         metadata = { | ||||||
|  |             "path": str(path), | ||||||
|  |             "type": file_type, | ||||||
|  |             "size_bytes": stat_result.st_size, | ||||||
|  |             "modification_time_utc": mod_time, | ||||||
|  |             "creation_time_utc": create_time, # Note platform differences in definition | ||||||
|  |             "last_metadata_change_time_utc": datetime.fromtimestamp(stat_result.st_ctime, tz=timezone.utc).isoformat(), | ||||||
|  |         } | ||||||
|  |         return metadata | ||||||
|  | 
 | ||||||
|  |     except PermissionError: | ||||||
|  |         raise HTTPException(status_code=403, detail=f"Permission denied to access metadata for {data.path}") | ||||||
|  |     except Exception as e: | ||||||
|  |         raise HTTPException(status_code=500, detail=f"Failed to get metadata for {data.path}: {e}") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @app.post("/search_content", summary="Search for content within files") | ||||||
|  | async def search_content(data: SearchContentRequest = Body(...)): | ||||||
|  |     """ | ||||||
|  |     Search for text content within files in a specified directory. | ||||||
|  |     """ | ||||||
|  |     base_path = normalize_path(data.path) | ||||||
|  |     results = [] | ||||||
|  |     search_query_lower = data.search_query.lower() | ||||||
|  | 
 | ||||||
|  |     if not base_path.is_dir(): | ||||||
|  |         raise HTTPException(status_code=400, detail="Provided path is not a directory") | ||||||
|  | 
 | ||||||
|  |     iterator = base_path.rglob(data.file_pattern) if data.recursive else base_path.glob(data.file_pattern) | ||||||
|  | 
 | ||||||
|  |     for item_path in iterator: | ||||||
|  |         if item_path.is_file(): | ||||||
|  |             try: | ||||||
|  |                 # Read file line by line to handle potentially large files and different encodings | ||||||
|  |                 with item_path.open("r", encoding="utf-8", errors="ignore") as f: | ||||||
|  |                     for line_num, line in enumerate(f, 1): | ||||||
|  |                         if search_query_lower in line.lower(): | ||||||
|  |                             results.append( | ||||||
|  |                                 { | ||||||
|  |                                     "file_path": str(item_path), | ||||||
|  |                                     "line_number": line_num, | ||||||
|  |                                     "line_content": line.strip(), | ||||||
|  |                                 } | ||||||
|  |                             ) | ||||||
|  |             except Exception as e: | ||||||
|  |                 # Log or handle files that cannot be read (e.g., permission errors, binary files) | ||||||
|  |                 print(f"Could not read or search file {item_path}: {e}") | ||||||
|  |                 continue | ||||||
|  | 
 | ||||||
|  |     return {"matches": results or ["No matches found"]} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| @app.get("/list_allowed_directories", summary="List access-permitted directories") | @app.get("/list_allowed_directories", summary="List access-permitted directories") | ||||||
| async def list_allowed_directories(): | async def list_allowed_directories(): | ||||||
|     """ |     """ | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user