diff --git a/Backend/Explorer.py b/Backend/Explorer.py index 9c48c34..60610f7 100644 --- a/Backend/Explorer.py +++ b/Backend/Explorer.py @@ -124,6 +124,28 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s RenameDirectoryNote(DestinationPath, OldName) +def SaveUploadedFile( + RootName: str, DirectoryRelativePath: str, FileName: str, FileBytes: bytes +) -> str: + DirectoryPath = ResolvePath(RootName, DirectoryRelativePath) + + if not DirectoryPath.is_dir(): + raise NotADirectoryError(f"{DirectoryPath} is not a directory.") + + SafeName = Path(FileName).name + + if not SafeName or SafeName in (".", ".."): + raise ValueError("Invalid file name.") + + TargetPath = DirectoryPath / SafeName + + if TargetPath.exists(): + raise FileExistsError(f"{SafeName} already exists.") + + TargetPath.write_bytes(FileBytes) + return SafeName + + def DeleteEntry(RootName: str, RelativePath: str) -> None: EntryPath = ResolvePath(RootName, RelativePath) diff --git a/Backend/Main.py b/Backend/Main.py index fbd28b9..d9ff8c2 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -2,8 +2,12 @@ from __future__ import annotations import json import mimetypes +import os import sys +import tempfile +import zipfile from pathlib import Path +from urllib.parse import quote ApplicationDirectory = Path(__file__).resolve().parent if str(ApplicationDirectory) not in sys.path: @@ -12,6 +16,7 @@ if str(ApplicationDirectory) not in sys.path: from fastapi import Depends, FastAPI, File, HTTPException, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, StreamingResponse +from starlette.background import BackgroundTask from Auth import ( CreateSession, @@ -34,6 +39,7 @@ from Explorer import ( ListDirectory, ListRoots, MoveEntry, + SaveUploadedFile, ) from Groups import ( CreateGroup, @@ -429,6 +435,36 @@ def PostTreeEndpoint( return {"Created": True} +@Application.post("/api/upload") +async def PostUploadEndpoint( + Root: str, + Path: str, + Files: list[UploadFile] = File(...), + CurrentUser: dict = Depends(GetCurrentUser), +) -> dict: + RequirePathAccessible(Root, Path, CurrentUser) + + SavedNames: list[str] = [] + Errors: list[str] = [] + + for UploadedFile in Files: + DisplayName = UploadedFile.filename or "upload" + + try: + FileBytes = await UploadedFile.read() + SavedName = SaveUploadedFile(Root, Path, DisplayName, FileBytes) + SavedNames.append(SavedName) + except (ValueError, FileExistsError, NotADirectoryError) as Error: + Errors.append(f"{DisplayName}: {Error}") + except OSError as Error: + Errors.append(f"{DisplayName}: Could not save file ({Error})") + + if Errors and not SavedNames: + raise HTTPException(status_code=400, detail="; ".join(Errors)) + + return {"Saved": SavedNames, "Errors": Errors} + + class MoveEntryRequest(BaseModel): NewName: str | None = None DestinationPath: str | None = None @@ -622,6 +658,81 @@ def GetRawFileEndpoint( return StreamingResponse(IterateFile(), media_type=MimeType) +def _AddDirectoryToZip( + ZipHandle: zipfile.ZipFile, + RootName: str, + RelativePath: str, + Username: str, + UserGroups: list[str], + ArcPrefix: str, +) -> None: + try: + Entries = ListDirectory(RootName, RelativePath, False, False) + except (ValueError, NotADirectoryError, FileNotFoundError): + return + + for Entry in Entries: + EntryRelativePath = f"{RelativePath}/{Entry['Name']}" if RelativePath else Entry["Name"] + + if not IsPathAccessible(RootName, EntryRelativePath, Username, UserGroups): + continue + + ArcName = f"{ArcPrefix}/{Entry['Name']}" if ArcPrefix else Entry["Name"] + + if Entry["IsDirectory"]: + _AddDirectoryToZip(ZipHandle, RootName, EntryRelativePath, Username, UserGroups, ArcName) + else: + ZipHandle.write(ResolvePath(RootName, EntryRelativePath), ArcName) + + +@Application.get("/api/download") +def GetDownloadEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)): + RequirePathAccessible(Root, Path, CurrentUser) + + try: + EntryPath = ResolvePath(Root, Path) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + if not EntryPath.exists(): + raise HTTPException(status_code=404, detail="Not found.") + + Username = CurrentUser["Username"] + UserGroups = CurrentUser.get("Groups", []) + DownloadName = EntryPath.name or Root + + if EntryPath.is_dir(): + TempZip = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + TempZip.close() + + with zipfile.ZipFile(TempZip.name, "w", zipfile.ZIP_DEFLATED) as ZipHandle: + _AddDirectoryToZip(ZipHandle, Root, Path, Username, UserGroups, "") + + EncodedName = quote(f"{DownloadName}.zip") + Headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{EncodedName}"} + + return FileResponse( + TempZip.name, + media_type="application/zip", + headers=Headers, + background=BackgroundTask(os.remove, TempZip.name), + ) + + if not EntryPath.is_file(): + raise HTTPException(status_code=400, detail="Not a file or directory.") + + MimeType = mimetypes.guess_type(str(EntryPath))[0] or "application/octet-stream" + EncodedName = quote(DownloadName) + Headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{EncodedName}"} + + def IterateDownload(): + with EntryPath.open("rb") as SourceFile: + while Chunk := SourceFile.read(1024 * 1024): + yield Chunk + + return StreamingResponse(IterateDownload(), media_type=MimeType, headers=Headers) + + @Application.get("/api/book") def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict: RequirePathAccessible(Root, Path, CurrentUser) diff --git a/Frontend/src/Api.js b/Frontend/src/Api.js index 067f4a6..2dafe8f 100644 --- a/Frontend/src/Api.js +++ b/Frontend/src/Api.js @@ -156,6 +156,25 @@ export function GetRawFileUrl(RootName, RelativePath) { return `${ApiBase}/raw/${encodeURIComponent(RootName)}/${EncodedSegments.join("/")}`; } +export function GetDownloadUrl(RootName, RelativePath) { + const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); + return `${ApiBase}/download?${Params}`; +} + +export async function UploadFiles(RootName, RelativePath, Files) { + const Form = new FormData(); + for (const File of Files) { + Form.append("Files", File); + } + const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); + const Response = await fetch(`${ApiBase}/upload?${Params}`, { + method: "POST", + credentials: "same-origin", + body: Form, + }); + return ParseJsonOrThrow(Response); +} + export function SaveFile(RootName, RelativePath, Content) { const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); return FetchJson(`/file?${Params}`, { method: "PUT", body: Content }); diff --git a/Frontend/src/Components/DirectoryPane.jsx b/Frontend/src/Components/DirectoryPane.jsx index a65f731..fa04113 100644 --- a/Frontend/src/Components/DirectoryPane.jsx +++ b/Frontend/src/Components/DirectoryPane.jsx @@ -1,13 +1,15 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { CreateEntry, DeleteEntry, FetchLock, FetchTree, + GetDownloadUrl, MoveEntry, RenameEntry, SetLock as SetLockApi, + UploadFiles, } from "../Api.js"; import { GetFileIcon, GetFolderIcon } from "../IconStyle.js"; import AccessModal from "./AccessModal.jsx"; @@ -31,6 +33,8 @@ export default function DirectoryPane({ const [LoadError, SetLoadError] = useState(null); const [ModalConfig, SetModalConfig] = useState(null); const [ErrorMessage, SetErrorMessage] = useState(null); + const [IsDraggingOver, SetIsDraggingOver] = useState(false); + const FileInputRef = useRef(null); async function Reload() { try { @@ -154,16 +158,71 @@ export default function DirectoryPane({ } } + async function HandleUpload(Files) { + if (!Files || Files.length === 0) return; + + try { + const Result = await UploadFiles(RootName, DirectoryPath, Files); + if (Result.Errors && Result.Errors.length > 0) { + SetErrorMessage(Result.Errors.join("; ")); + } + await Reload(); + OnEntryCreated(); + } catch (Error) { + SetErrorMessage(Error.message); + } + } + + function HandleUploadButtonClick() { + FileInputRef.current?.click(); + } + + function HandleFileInputChange(Event) { + HandleUpload(Event.target.files); + Event.target.value = ""; + } + + function HandleDragOver(Event) { + if (IsGuest) return; + Event.preventDefault(); + SetIsDraggingOver(true); + } + + function HandleDragLeave(Event) { + Event.preventDefault(); + SetIsDraggingOver(false); + } + + function HandleDrop(Event) { + Event.preventDefault(); + SetIsDraggingOver(false); + if (IsGuest) return; + HandleUpload(Event.dataTransfer.files); + } + return (
{ResolvedContent}