Add file upload and download (zip for directories)

This commit is contained in:
sarah committed 2026-09-28 22:57:09 +02:00
1 parent 0d564982dc
commit da4e02472f
7 files changed
+383 -42

No files matched your search

+22
View File
@@ -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)
+111
View File
@@ -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)
+19
View File
@@ -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 });
+96 -27
View File
@@ -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 (
<div className="DirectoryPane">
{!IsGuest && (
<div className="PreviewToolbar">
<button onClick={OpenCreateFileModal}>New File</button>
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
<button onClick={HandleUploadButtonClick}>Upload</button>
<input
ref={FileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={HandleFileInputChange}
/>
</div>
)}
{LoadError && <div className="SidebarError">{LoadError}</div>}
<div className="DirectoryList">
<div
className={`DirectoryList${IsDraggingOver ? " DraggingOver" : ""}`}
onDragOver={HandleDragOver}
onDragLeave={HandleDragLeave}
onDrop={HandleDrop}
>
{Entries.map((Entry) => (
<div
className="DirectoryEntry"
@@ -178,31 +237,41 @@ export default function DirectoryPane({
: GetFileIcon(UseHyphenBullets)}
</span>
<span className="TreeLabel">{Entry.Name}</span>
{!IsGuest && (
<span className="TreeRowActions">
<button
className="TreeRowActionButton"
onClick={(Event) => HandleRequestLockEdit(Event, Entry)}
title="Access control"
>
🔒
</button>
<button
className="TreeRowActionButton"
onClick={(Event) => HandleRenameClick(Event, Entry)}
title="Rename / move"
>
✎
</button>
<button
className="TreeRowActionButton"
onClick={(Event) => HandleDeleteClick(Event, Entry)}
title="Delete"
>
✕
</button>
</span>
)}
<span className="TreeRowActions">
<a
className="TreeRowActionButton"
href={GetDownloadUrl(RootName, JoinPath(Entry.Name))}
onClick={(Event) => Event.stopPropagation()}
title={Entry.IsDirectory ? "Download as .zip" : "Download"}
>
⭳
</a>
{!IsGuest && (
<>
<button
className="TreeRowActionButton"
onClick={(Event) => HandleRequestLockEdit(Event, Entry)}
title="Access control"
>
🔒
</button>
<button
className="TreeRowActionButton"
onClick={(Event) => HandleRenameClick(Event, Entry)}
title="Rename / move"
>
✎
</button>
<button
className="TreeRowActionButton"
onClick={(Event) => HandleDeleteClick(Event, Entry)}
title="Delete"
>
✕
</button>
</>
)}
</span>
</div>
))}
{Entries.length === 0 && !LoadError && (
+23
View File
@@ -14,6 +14,7 @@ import {
FetchFile,
FetchResolvedFile,
GetAvatarUrl,
GetDownloadUrl,
GetFileUrl,
GetRawFileUrl,
SaveFile,
@@ -243,6 +244,11 @@ export default function PreviewPane({
if (FileType === "image") {
return (
<div className="PreviewMedia">
<div className={ToolbarClass}>
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div>
<img src={GetFileUrl(RootName, FilePath)} alt={FilePath} />
</div>
);
@@ -251,6 +257,11 @@ export default function PreviewPane({
if (FileType === "video") {
return (
<div className="PreviewMedia">
<div className={ToolbarClass}>
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div>
<video controls src={GetFileUrl(RootName, FilePath)} />
</div>
);
@@ -261,6 +272,9 @@ export default function PreviewPane({
<div className="PreviewFrame">
<div className={ToolbarClass}>
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div>
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
</div>
@@ -272,6 +286,9 @@ export default function PreviewPane({
<div className="PreviewMarkdown">
<div className={ToolbarClass}>
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div>
<div
className="MarkdownBody"
@@ -294,6 +311,9 @@ export default function PreviewPane({
<div className="PreviewText">
<div className={ToolbarClass}>
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div>
<pre className="TextBody">{ResolvedContent}</pre>
</div>
@@ -312,6 +332,9 @@ export default function PreviewPane({
{IsSaving ? "Saving…" : "Save"}
</button>
)}
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
</div>
<CodeMirror
+86 -15
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import {
CreateEntry,
@@ -6,9 +6,11 @@ import {
FetchLock,
FetchTree,
GetAvatarUrl,
GetDownloadUrl,
MoveEntry,
RenameEntry,
SetLock as SetLockApi,
UploadFiles,
} from "../Api.js";
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
import AccessModal from "./AccessModal.jsx";
@@ -151,19 +153,29 @@ function TreeNode({
: GetFileIcon(UseHyphenBullets)}
</span>
<span className="TreeLabel">{Entry.Name}</span>
{!IsGuest && (
<span className="TreeRowActions">
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control">
🔒
</button>
<button className="TreeRowActionButton" onClick={HandleRenameClick} title="Rename / move">
✎
</button>
<button className="TreeRowActionButton" onClick={HandleDeleteClick} title="Delete">
✕
</button>
</span>
)}
<span className="TreeRowActions">
<a
className="TreeRowActionButton"
href={GetDownloadUrl(RootName, FullPath)}
onClick={(Event) => Event.stopPropagation()}
title={Entry.IsDirectory ? "Download as .zip" : "Download"}
>
⭳
</a>
{!IsGuest && (
<>
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control">
🔒
</button>
<button className="TreeRowActionButton" onClick={HandleRenameClick} title="Rename / move">
✎
</button>
<button className="TreeRowActionButton" onClick={HandleDeleteClick} title="Delete">
✕
</button>
</>
)}
</span>
</div>
{IsRenaming && (
<PromptModal
@@ -284,6 +296,8 @@ export default function Sidebar({
const [LoadError, SetLoadError] = useState(null);
const [ModalConfig, SetModalConfig] = useState(null);
const [ErrorMessage, SetErrorMessage] = useState(null);
const [IsDraggingOver, SetIsDraggingOver] = useState(false);
const FileInputRef = useRef(null);
useEffect(() => {
if (RootName) {
@@ -345,6 +359,47 @@ export default function Sidebar({
}
}
async function HandleUpload(Files) {
if (!Files || Files.length === 0) return;
try {
const Result = await UploadFiles(RootName, CurrentDirectoryPath, Files);
if (Result.Errors && Result.Errors.length > 0) {
SetErrorMessage(Result.Errors.join("; "));
}
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 (
<div className="Sidebar">
<div className="SidebarHeader">
@@ -364,10 +419,25 @@ export default function Sidebar({
<div className="SidebarNewEntry SidebarNewEntryTop">
<button onClick={OpenCreateFileModal}>New File</button>
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
<button onClick={HandleUploadButtonClick}>Upload</button>
</div>
)}
{!IsGuest && (
<input
ref={FileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={HandleFileInputChange}
/>
)}
{LoadError && <div className="SidebarError">{LoadError}</div>}
<div className="SidebarTree">
<div
className={`SidebarTree${IsDraggingOver ? " DraggingOver" : ""}`}
onDragOver={HandleDragOver}
onDragLeave={HandleDragLeave}
onDrop={HandleDrop}
>
{RootEntries.map((Entry) => (
<TreeNode
key={Entry.Name}
@@ -395,6 +465,7 @@ export default function Sidebar({
<div className="SidebarNewEntry">
<button onClick={OpenCreateFileModal}>New File</button>
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
<button onClick={HandleUploadButtonClick}>Upload</button>
</div>
)}
+26
View File
@@ -227,6 +227,10 @@ img,
line-height: 1;
font-size: 11px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.TreeRowActionButton:hover {
@@ -309,6 +313,13 @@ img,
padding: 8px 0;
}
.DirectoryList.DraggingOver,
.SidebarTree.DraggingOver {
outline: 2px dashed var(--Purple);
outline-offset: -2px;
background: var(--SurfaceAlt);
}
.DirectoryEntry {
display: flex;
align-items: center;
@@ -370,6 +381,21 @@ img,
cursor: default;
}
.DownloadButton {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 4px 10px;
font-family: inherit;
cursor: pointer;
text-decoration: none;
}
.DownloadButton:hover {
background: var(--Border);
}
.SaveErrorText {
color: var(--Red);
align-self: center;