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) 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: def DeleteEntry(RootName: str, RelativePath: str) -> None:
EntryPath = ResolvePath(RootName, RelativePath) EntryPath = ResolvePath(RootName, RelativePath)
+111
View File
@@ -2,8 +2,12 @@ from __future__ import annotations
import json import json
import mimetypes import mimetypes
import os
import sys import sys
import tempfile
import zipfile
from pathlib import Path from pathlib import Path
from urllib.parse import quote
ApplicationDirectory = Path(__file__).resolve().parent ApplicationDirectory = Path(__file__).resolve().parent
if str(ApplicationDirectory) not in sys.path: 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 import Depends, FastAPI, File, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, StreamingResponse
from starlette.background import BackgroundTask
from Auth import ( from Auth import (
CreateSession, CreateSession,
@@ -34,6 +39,7 @@ from Explorer import (
ListDirectory, ListDirectory,
ListRoots, ListRoots,
MoveEntry, MoveEntry,
SaveUploadedFile,
) )
from Groups import ( from Groups import (
CreateGroup, CreateGroup,
@@ -429,6 +435,36 @@ def PostTreeEndpoint(
return {"Created": True} 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): class MoveEntryRequest(BaseModel):
NewName: str | None = None NewName: str | None = None
DestinationPath: str | None = None DestinationPath: str | None = None
@@ -622,6 +658,81 @@ def GetRawFileEndpoint(
return StreamingResponse(IterateFile(), media_type=MimeType) 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") @Application.get("/api/book")
def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict: def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict:
RequirePathAccessible(Root, Path, CurrentUser) RequirePathAccessible(Root, Path, CurrentUser)
+19
View File
@@ -156,6 +156,25 @@ export function GetRawFileUrl(RootName, RelativePath) {
return `${ApiBase}/raw/${encodeURIComponent(RootName)}/${EncodedSegments.join("/")}`; 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) { export function SaveFile(RootName, RelativePath, Content) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath }); const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/file?${Params}`, { method: "PUT", body: Content }); 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 { import {
CreateEntry, CreateEntry,
DeleteEntry, DeleteEntry,
FetchLock, FetchLock,
FetchTree, FetchTree,
GetDownloadUrl,
MoveEntry, MoveEntry,
RenameEntry, RenameEntry,
SetLock as SetLockApi, SetLock as SetLockApi,
UploadFiles,
} from "../Api.js"; } from "../Api.js";
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js"; import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
import AccessModal from "./AccessModal.jsx"; import AccessModal from "./AccessModal.jsx";
@@ -31,6 +33,8 @@ export default function DirectoryPane({
const [LoadError, SetLoadError] = useState(null); const [LoadError, SetLoadError] = useState(null);
const [ModalConfig, SetModalConfig] = useState(null); const [ModalConfig, SetModalConfig] = useState(null);
const [ErrorMessage, SetErrorMessage] = useState(null); const [ErrorMessage, SetErrorMessage] = useState(null);
const [IsDraggingOver, SetIsDraggingOver] = useState(false);
const FileInputRef = useRef(null);
async function Reload() { async function Reload() {
try { 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 ( return (
<div className="DirectoryPane"> <div className="DirectoryPane">
{!IsGuest && ( {!IsGuest && (
<div className="PreviewToolbar"> <div className="PreviewToolbar">
<button onClick={OpenCreateFileModal}>New File</button> <button onClick={OpenCreateFileModal}>New File</button>
<button onClick={OpenCreateDirectoryModal}>New Directory</button> <button onClick={OpenCreateDirectoryModal}>New Directory</button>
<button onClick={HandleUploadButtonClick}>Upload</button>
<input
ref={FileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={HandleFileInputChange}
/>
</div> </div>
)} )}
{LoadError && <div className="SidebarError">{LoadError}</div>} {LoadError && <div className="SidebarError">{LoadError}</div>}
<div className="DirectoryList"> <div
className={`DirectoryList${IsDraggingOver ? " DraggingOver" : ""}`}
onDragOver={HandleDragOver}
onDragLeave={HandleDragLeave}
onDrop={HandleDrop}
>
{Entries.map((Entry) => ( {Entries.map((Entry) => (
<div <div
className="DirectoryEntry" className="DirectoryEntry"
@@ -178,31 +237,41 @@ export default function DirectoryPane({
: GetFileIcon(UseHyphenBullets)} : GetFileIcon(UseHyphenBullets)}
</span> </span>
<span className="TreeLabel">{Entry.Name}</span> <span className="TreeLabel">{Entry.Name}</span>
{!IsGuest && ( <span className="TreeRowActions">
<span className="TreeRowActions"> <a
<button className="TreeRowActionButton"
className="TreeRowActionButton" href={GetDownloadUrl(RootName, JoinPath(Entry.Name))}
onClick={(Event) => HandleRequestLockEdit(Event, Entry)} onClick={(Event) => Event.stopPropagation()}
title="Access control" title={Entry.IsDirectory ? "Download as .zip" : "Download"}
> >
🔒 ⭳
</button> </a>
<button {!IsGuest && (
className="TreeRowActionButton" <>
onClick={(Event) => HandleRenameClick(Event, Entry)} <button
title="Rename / move" className="TreeRowActionButton"
> onClick={(Event) => HandleRequestLockEdit(Event, Entry)}
✎ title="Access control"
</button> >
<button 🔒
className="TreeRowActionButton" </button>
onClick={(Event) => HandleDeleteClick(Event, Entry)} <button
title="Delete" className="TreeRowActionButton"
> onClick={(Event) => HandleRenameClick(Event, Entry)}
✕ title="Rename / move"
</button> >
</span> ✎
)} </button>
<button
className="TreeRowActionButton"
onClick={(Event) => HandleDeleteClick(Event, Entry)}
title="Delete"
>
✕
</button>
</>
)}
</span>
</div> </div>
))} ))}
{Entries.length === 0 && !LoadError && ( {Entries.length === 0 && !LoadError && (
+23
View File
@@ -14,6 +14,7 @@ import {
FetchFile, FetchFile,
FetchResolvedFile, FetchResolvedFile,
GetAvatarUrl, GetAvatarUrl,
GetDownloadUrl,
GetFileUrl, GetFileUrl,
GetRawFileUrl, GetRawFileUrl,
SaveFile, SaveFile,
@@ -243,6 +244,11 @@ export default function PreviewPane({
if (FileType === "image") { if (FileType === "image") {
return ( return (
<div className="PreviewMedia"> <div className="PreviewMedia">
<div className={ToolbarClass}>
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div>
<img src={GetFileUrl(RootName, FilePath)} alt={FilePath} /> <img src={GetFileUrl(RootName, FilePath)} alt={FilePath} />
</div> </div>
); );
@@ -251,6 +257,11 @@ export default function PreviewPane({
if (FileType === "video") { if (FileType === "video") {
return ( return (
<div className="PreviewMedia"> <div className="PreviewMedia">
<div className={ToolbarClass}>
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div>
<video controls src={GetFileUrl(RootName, FilePath)} /> <video controls src={GetFileUrl(RootName, FilePath)} />
</div> </div>
); );
@@ -261,6 +272,9 @@ export default function PreviewPane({
<div className="PreviewFrame"> <div className="PreviewFrame">
<div className={ToolbarClass}> <div className={ToolbarClass}>
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>} {!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div> </div>
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} /> <iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
</div> </div>
@@ -272,6 +286,9 @@ export default function PreviewPane({
<div className="PreviewMarkdown"> <div className="PreviewMarkdown">
<div className={ToolbarClass}> <div className={ToolbarClass}>
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>} {!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div> </div>
<div <div
className="MarkdownBody" className="MarkdownBody"
@@ -294,6 +311,9 @@ export default function PreviewPane({
<div className="PreviewText"> <div className="PreviewText">
<div className={ToolbarClass}> <div className={ToolbarClass}>
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>} {!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
</div> </div>
<pre className="TextBody">{ResolvedContent}</pre> <pre className="TextBody">{ResolvedContent}</pre>
</div> </div>
@@ -312,6 +332,9 @@ export default function PreviewPane({
{IsSaving ? "Saving…" : "Save"} {IsSaving ? "Saving…" : "Save"}
</button> </button>
)} )}
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
Download
</a>
{SaveError && <span className="SaveErrorText">{SaveError}</span>} {SaveError && <span className="SaveErrorText">{SaveError}</span>}
</div> </div>
<CodeMirror <CodeMirror
+86 -15
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { import {
CreateEntry, CreateEntry,
@@ -6,9 +6,11 @@ import {
FetchLock, FetchLock,
FetchTree, FetchTree,
GetAvatarUrl, GetAvatarUrl,
GetDownloadUrl,
MoveEntry, MoveEntry,
RenameEntry, RenameEntry,
SetLock as SetLockApi, SetLock as SetLockApi,
UploadFiles,
} from "../Api.js"; } from "../Api.js";
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js"; import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
import AccessModal from "./AccessModal.jsx"; import AccessModal from "./AccessModal.jsx";
@@ -151,19 +153,29 @@ function TreeNode({
: GetFileIcon(UseHyphenBullets)} : GetFileIcon(UseHyphenBullets)}
</span> </span>
<span className="TreeLabel">{Entry.Name}</span> <span className="TreeLabel">{Entry.Name}</span>
{!IsGuest && ( <span className="TreeRowActions">
<span className="TreeRowActions"> <a
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control"> className="TreeRowActionButton"
🔒 href={GetDownloadUrl(RootName, FullPath)}
</button> onClick={(Event) => Event.stopPropagation()}
<button className="TreeRowActionButton" onClick={HandleRenameClick} title="Rename / move"> title={Entry.IsDirectory ? "Download as .zip" : "Download"}
✎ >
</button> ⭳
<button className="TreeRowActionButton" onClick={HandleDeleteClick} title="Delete"> </a>
✕ {!IsGuest && (
</button> <>
</span> <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> </div>
{IsRenaming && ( {IsRenaming && (
<PromptModal <PromptModal
@@ -284,6 +296,8 @@ export default function Sidebar({
const [LoadError, SetLoadError] = useState(null); const [LoadError, SetLoadError] = useState(null);
const [ModalConfig, SetModalConfig] = useState(null); const [ModalConfig, SetModalConfig] = useState(null);
const [ErrorMessage, SetErrorMessage] = useState(null); const [ErrorMessage, SetErrorMessage] = useState(null);
const [IsDraggingOver, SetIsDraggingOver] = useState(false);
const FileInputRef = useRef(null);
useEffect(() => { useEffect(() => {
if (RootName) { 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 ( return (
<div className="Sidebar"> <div className="Sidebar">
<div className="SidebarHeader"> <div className="SidebarHeader">
@@ -364,10 +419,25 @@ export default function Sidebar({
<div className="SidebarNewEntry SidebarNewEntryTop"> <div className="SidebarNewEntry SidebarNewEntryTop">
<button onClick={OpenCreateFileModal}>New File</button> <button onClick={OpenCreateFileModal}>New File</button>
<button onClick={OpenCreateDirectoryModal}>New Directory</button> <button onClick={OpenCreateDirectoryModal}>New Directory</button>
<button onClick={HandleUploadButtonClick}>Upload</button>
</div> </div>
)} )}
{!IsGuest && (
<input
ref={FileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={HandleFileInputChange}
/>
)}
{LoadError && <div className="SidebarError">{LoadError}</div>} {LoadError && <div className="SidebarError">{LoadError}</div>}
<div className="SidebarTree"> <div
className={`SidebarTree${IsDraggingOver ? " DraggingOver" : ""}`}
onDragOver={HandleDragOver}
onDragLeave={HandleDragLeave}
onDrop={HandleDrop}
>
{RootEntries.map((Entry) => ( {RootEntries.map((Entry) => (
<TreeNode <TreeNode
key={Entry.Name} key={Entry.Name}
@@ -395,6 +465,7 @@ export default function Sidebar({
<div className="SidebarNewEntry"> <div className="SidebarNewEntry">
<button onClick={OpenCreateFileModal}>New File</button> <button onClick={OpenCreateFileModal}>New File</button>
<button onClick={OpenCreateDirectoryModal}>New Directory</button> <button onClick={OpenCreateDirectoryModal}>New Directory</button>
<button onClick={HandleUploadButtonClick}>Upload</button>
</div> </div>
)} )}
+26
View File
@@ -227,6 +227,10 @@ img,
line-height: 1; line-height: 1;
font-size: 11px; font-size: 11px;
cursor: pointer; cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
} }
.TreeRowActionButton:hover { .TreeRowActionButton:hover {
@@ -309,6 +313,13 @@ img,
padding: 8px 0; padding: 8px 0;
} }
.DirectoryList.DraggingOver,
.SidebarTree.DraggingOver {
outline: 2px dashed var(--Purple);
outline-offset: -2px;
background: var(--SurfaceAlt);
}
.DirectoryEntry { .DirectoryEntry {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -370,6 +381,21 @@ img,
cursor: default; 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 { .SaveErrorText {
color: var(--Red); color: var(--Red);
align-self: center; align-self: center;