From 17565b951364cc15cde49ebf854df83cd0470c16 Mon Sep 17 00:00:00 2001 From: priya_kanabar-crest Date: Fri, 18 Oct 2024 09:56:59 +0530 Subject: [PATCH] chore(file-download): enable downloading folder/file contents from file directory --- setup.cfg | 1 + zt_backend/models/api/request.py | 5 + zt_backend/router.py | 136 +++++++++++++++++- zt_frontend/src/components/FileExplorer.vue | 42 +++++- .../components/FileFolderDownloadDialog.vue | 108 ++++++++++++++ 5 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 zt_frontend/src/components/FileFolderDownloadDialog.vue diff --git a/setup.cfg b/setup.cfg index 3330e39c..b1930577 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,7 @@ install_requires = setuptools<=68.2.2 pygwalker<=0.4.8.1 openai<=1.1.0 + aiofiles<=24.1.0 python-multipart<=0.0.9 ruff<=0.6.7 python_requires = >=3.9 diff --git a/zt_backend/models/api/request.py b/zt_backend/models/api/request.py index f2badb10..c6311b57 100644 --- a/zt_backend/models/api/request.py +++ b/zt_backend/models/api/request.py @@ -171,3 +171,8 @@ class RenameItemRequest(BaseModel): class DeleteItemRequest(BaseModel): path: str name: str + +class DownloadRequest(BaseModel): + path: str + filename: str + isFolder: bool \ No newline at end of file diff --git a/zt_backend/router.py b/zt_backend/router.py index d7170ece..175fddf3 100644 --- a/zt_backend/router.py +++ b/zt_backend/router.py @@ -34,7 +34,8 @@ from zt_backend.models.state.user_state import UserState from zt_backend.models.state.app_state import AppState from zt_backend.models.state.notebook_state import UploadState -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, StreamingResponse, Response +from fastapi.requests import Request from pathlib import Path import logging import uuid @@ -46,6 +47,11 @@ import requests import re from zt_backend.utils.file_utils import upload_queue, process_upload +import mimetypes +from typing import Dict, Tuple, Optional +import aiofiles +import tempfile +import zipfile router = APIRouter() manager = ConnectionManager() @@ -727,7 +733,135 @@ def delete_item(delete_request: request.DeleteItemRequest): status_code=500, detail=f"An unexpected error occurred: {str(e)}" ) +def initialize_mime_types(): + """Initialize and customize MIME types.""" + mimetypes.init() + + custom_mime_types: Dict[str, str] = { + '.md': 'text/markdown', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.7z': 'application/x-7z-compressed', + '.rar': 'application/vnd.rar', + '.webp': 'image/webp', + # Add more custom MIME types here as needed + } + + for ext, mime_type in custom_mime_types.items(): + mimetypes.add_type(mime_type, ext) + +def get_mime_type(filename: str) -> str: + """Determine the MIME type of a file based on its filename.""" + initialize_mime_types() + return mimetypes.guess_type(filename)[0] or 'application/octet-stream' + +def validate_path(path: Path, filename: str): + """Validate the path and raise appropriate exceptions if invalid.""" + if not path.exists(): + raise HTTPException(status_code=404, detail=f"Path not found: {filename}") + +async def create_zip_file(folder_path: Path, temp_zip_path: str): + """Create a zip file from a folder.""" + with zipfile.ZipFile(temp_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, _, files in os.walk(folder_path): + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, start=str(folder_path)) + zipf.write(file_path, arcname) + +async def parse_range_header(range_header: Optional[str], file_size: int) -> Tuple[int, int]: + """Parse Range header and return start and end bytes.""" + if not range_header: + return 0, file_size - 1 + + try: + range_str = range_header.replace('bytes=', '') + start_str, end_str = range_str.split('-') + start = int(start_str) + end = int(end_str) if end_str else file_size - 1 + return start, min(end, file_size - 1) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid range header") + +async def stream_file_range(file_path: str, start: int, end: int, chunk_size: int = 8192): + """Stream file content for the specified byte range.""" + async with aiofiles.open(file_path, mode='rb') as file: + await file.seek(start) + bytes_remaining = end - start + 1 + + while bytes_remaining > 0: + chunk_size = min(chunk_size, bytes_remaining) + chunk = await file.read(chunk_size) + if not chunk: + break + yield chunk + bytes_remaining -= len(chunk) + +@router.get("/api/download") +async def download_item( + request: Request, + path: str, + filename: str, + isFolder: bool, + chunk_size: int = 8192 +): + """Stream download with range support and error handling for both files and folders.""" + try: + file_path = Path(path) + validate_path(file_path, filename) + + if isFolder: + # Create a temporary zip file + with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_zip: + temp_zip_path = temp_zip.name + + # Create a zip file containing the folder contents + await create_zip_file(file_path, temp_zip_path) + file_path = Path(temp_zip_path) + filename = f"{filename}.zip" + elif not file_path.is_file(): + raise HTTPException(status_code=400, detail=f"The path specified is not a file: {filename}") + + file_size = file_path.stat().st_size + start, end = await parse_range_header( + request.headers.get('range'), + file_size + ) + + # Create response with proper headers + response = StreamingResponse( + stream_file_range(str(file_path), start, end, chunk_size), + status_code=206 if request.headers.get('range') else 200, + media_type=get_mime_type(filename) + ) + + # Set headers + response.headers.update({ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Content-Length": str(end - start + 1), + "Content-Disposition": f"attachment; filename={filename}", + "Accept-Ranges": "bytes", + "Cache-Control": "no-cache", + "Content-Encoding": "identity" + }) + + if isFolder: + # Clean up the temporary zip file after streaming + async def cleanup_temp_file(): + yield + os.unlink(temp_zip_path) + + response.background = cleanup_temp_file() + return response + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Download failed: {str(e)}" + ) + def get_file_type(name): extension = name.split(".")[-1] if extension in ["html", "js", "json", "md", "pdf", "png", "txt", "xls"]: diff --git a/zt_frontend/src/components/FileExplorer.vue b/zt_frontend/src/components/FileExplorer.vue index 73ba9d83..ebbe92c1 100644 --- a/zt_frontend/src/components/FileExplorer.vue +++ b/zt_frontend/src/components/FileExplorer.vue @@ -38,10 +38,10 @@ {{ fileIcon(item.file) }} - {{ item.title }} + {{ item.title }} + + | null>(null); const renameDialog = ref | null>(null); const deleteDialog = ref | null>(null); + const openDownloadDialog = (item: any) => { + downloadDialog.value?.openDialog(item); + }; const openRenameDialog = (item: any) => { renameDialog.value?.openDialog(item); @@ -250,6 +270,8 @@ export default defineComponent({ fileIcon, newItemName, itemTypes, + downloadDialog, + openDownloadDialog, renameDialog, openRenameDialog, openDeleteDialog, @@ -261,3 +283,13 @@ export default defineComponent({ }, }); + + \ No newline at end of file diff --git a/zt_frontend/src/components/FileFolderDownloadDialog.vue b/zt_frontend/src/components/FileFolderDownloadDialog.vue new file mode 100644 index 00000000..05d55502 --- /dev/null +++ b/zt_frontend/src/components/FileFolderDownloadDialog.vue @@ -0,0 +1,108 @@ + + \ No newline at end of file