feat: download - fase 02
This commit is contained in:
Binary file not shown.
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from backend.app.api.schemas import DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, UploadResponse, ViewResponse
|
from backend.app.api.schemas import DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, UploadResponse, ViewResponse
|
||||||
from backend.app.dependencies import get_file_ops_service
|
from backend.app.dependencies import get_file_ops_service
|
||||||
@@ -63,15 +64,18 @@ async def info(
|
|||||||
|
|
||||||
@router.get("/download")
|
@router.get("/download")
|
||||||
async def download(
|
async def download(
|
||||||
path: str,
|
path: list[str] = Query(...),
|
||||||
service: FileOpsService = Depends(get_file_ops_service),
|
service: FileOpsService = Depends(get_file_ops_service),
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
prepared = service.prepare_download(path=path)
|
prepared = service.prepare_download(paths=path)
|
||||||
return StreamingResponse(
|
response = StreamingResponse(
|
||||||
prepared["content"],
|
prepared["content"],
|
||||||
headers=prepared["headers"],
|
headers=prepared["headers"],
|
||||||
media_type=prepared["content_type"],
|
media_type=prepared["content_type"],
|
||||||
)
|
)
|
||||||
|
if prepared.get("cleanup"):
|
||||||
|
response.background = BackgroundTask(prepared["cleanup"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/video")
|
@router.get("/video")
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from io import BytesIO
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.app.api.errors import AppError
|
from backend.app.api.errors import AppError
|
||||||
@@ -353,31 +357,18 @@ class FileOpsService:
|
|||||||
height=metadata["height"],
|
height=metadata["height"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def prepare_download(self, path: str) -> dict:
|
def prepare_download(self, paths: list[str]) -> dict:
|
||||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
if not paths:
|
||||||
|
|
||||||
if resolved_target.absolute.is_dir():
|
|
||||||
raise AppError(
|
raise AppError(
|
||||||
code="type_conflict",
|
code="invalid_request",
|
||||||
message="Source must be a file",
|
message="At least one path is required",
|
||||||
status_code=409,
|
status_code=400,
|
||||||
details={"path": resolved_target.relative},
|
|
||||||
)
|
|
||||||
if not resolved_target.absolute.is_file():
|
|
||||||
raise AppError(
|
|
||||||
code="type_conflict",
|
|
||||||
message="Unsupported path type for download",
|
|
||||||
status_code=409,
|
|
||||||
details={"path": resolved_target.relative},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
resolved_targets = [self._path_guard.resolve_existing_path(path) for path in paths]
|
||||||
"content": self._filesystem.stream_file(resolved_target.absolute),
|
if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_file():
|
||||||
"headers": {
|
return self._prepare_single_file_download(resolved_targets[0])
|
||||||
"Content-Disposition": f'attachment; filename="{resolved_target.absolute.name}"',
|
return self._prepare_zip_download(resolved_targets)
|
||||||
},
|
|
||||||
"content_type": self._content_type_for(resolved_target.absolute) or "application/octet-stream",
|
|
||||||
}
|
|
||||||
|
|
||||||
def save(self, path: str, content: str, expected_modified: str) -> SaveResponse:
|
def save(self, path: str, content: str, expected_modified: str) -> SaveResponse:
|
||||||
resolved_target = self._path_guard.resolve_existing_path(path)
|
resolved_target = self._path_guard.resolve_existing_path(path)
|
||||||
@@ -660,9 +651,104 @@ class FileOpsService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
def _prepare_single_file_download(self, resolved_target) -> dict:
|
||||||
|
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(resolved_target.relative)
|
||||||
|
if lexical_source.is_symlink():
|
||||||
|
raise AppError(
|
||||||
|
code="type_conflict",
|
||||||
|
message="Source must not be a symlink",
|
||||||
|
status_code=409,
|
||||||
|
details={"path": resolved_target.relative},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"content": self._filesystem.stream_file(resolved_target.absolute),
|
||||||
|
"headers": {
|
||||||
|
"Content-Disposition": f'attachment; filename="{resolved_target.absolute.name}"',
|
||||||
|
},
|
||||||
|
"content_type": self._content_type_for(resolved_target.absolute) or "application/octet-stream",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _prepare_zip_download(self, resolved_targets: list) -> dict:
|
||||||
|
archive_names: set[str] = set()
|
||||||
|
for resolved_target in resolved_targets:
|
||||||
|
self._validate_download_target(resolved_target)
|
||||||
|
archive_name = resolved_target.absolute.name
|
||||||
|
if archive_name in archive_names:
|
||||||
|
raise AppError(
|
||||||
|
code="invalid_request",
|
||||||
|
message="Selected items must have distinct top-level names",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
archive_names.add(archive_name)
|
||||||
|
|
||||||
|
if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_dir():
|
||||||
|
download_name = f"{resolved_targets[0].absolute.name}.zip"
|
||||||
|
else:
|
||||||
|
download_name = f"kodidownload-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.zip"
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for resolved_target in resolved_targets:
|
||||||
|
self._write_download_target_to_zip(archive, resolved_target)
|
||||||
|
payload = buffer.getvalue()
|
||||||
|
|
||||||
|
async def _stream_zip():
|
||||||
|
yield payload
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content": _stream_zip(),
|
||||||
|
"headers": {
|
||||||
|
"Content-Disposition": f'attachment; filename="{download_name}"',
|
||||||
|
},
|
||||||
|
"content_type": "application/zip",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _validate_download_target(self, resolved_target) -> None:
|
||||||
|
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(resolved_target.relative)
|
||||||
|
if lexical_source.is_symlink():
|
||||||
|
raise AppError(
|
||||||
|
code="type_conflict",
|
||||||
|
message="Source must not be a symlink",
|
||||||
|
status_code=409,
|
||||||
|
details={"path": resolved_target.relative},
|
||||||
|
)
|
||||||
|
if resolved_target.absolute.is_file():
|
||||||
|
return
|
||||||
|
if resolved_target.absolute.is_dir():
|
||||||
|
for root, dirnames, filenames in os.walk(resolved_target.absolute, followlinks=False):
|
||||||
|
root_path = Path(root)
|
||||||
|
for name in [*dirnames, *filenames]:
|
||||||
|
entry = root_path / name
|
||||||
|
if entry.is_symlink():
|
||||||
|
raise AppError(
|
||||||
|
code="type_conflict",
|
||||||
|
message="Source directory must not contain symlinks",
|
||||||
|
status_code=409,
|
||||||
|
details={"path": resolved_target.relative},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise AppError(
|
||||||
|
code="type_conflict",
|
||||||
|
message="Unsupported path type for download",
|
||||||
|
status_code=409,
|
||||||
|
details={"path": resolved_target.relative},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None:
|
||||||
|
root_name = resolved_target.absolute.name
|
||||||
|
if resolved_target.absolute.is_file():
|
||||||
|
archive.write(resolved_target.absolute, arcname=root_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
archive.writestr(f"{root_name}/", b"")
|
||||||
|
for child in sorted(resolved_target.absolute.rglob("*")):
|
||||||
|
arcname = f"{root_name}/{child.relative_to(resolved_target.absolute).as_posix()}"
|
||||||
|
if child.is_dir():
|
||||||
|
archive.writestr(f"{arcname}/", b"")
|
||||||
|
else:
|
||||||
|
archive.write(child, arcname=arcname)
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
|
def _parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
|
||||||
def invalid_range() -> AppError:
|
def invalid_range() -> AppError:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,8 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -55,6 +57,75 @@ class DownloadApiGoldenTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_download_directory_type_conflict(self) -> None:
|
def test_download_directory_type_conflict(self) -> None:
|
||||||
(self.root / "docs").mkdir()
|
(self.root / "docs").mkdir()
|
||||||
|
(self.root / "docs" / "a.txt").write_text("a", encoding="utf-8")
|
||||||
|
|
||||||
|
response = self._get("/api/files/download?path=storage1/docs")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('attachment; filename="docs.zip"', response.headers.get("content-disposition", ""))
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
self.assertIn("docs/", archive.namelist())
|
||||||
|
self.assertIn("docs/a.txt", archive.namelist())
|
||||||
|
self.assertEqual(archive.read("docs/a.txt"), b"a")
|
||||||
|
|
||||||
|
def test_download_multi_file_selection_as_zip(self) -> None:
|
||||||
|
(self.root / "a.txt").write_text("A", encoding="utf-8")
|
||||||
|
(self.root / "b.txt").write_text("B", encoding="utf-8")
|
||||||
|
|
||||||
|
response = self._get("/api/files/download?path=storage1/a.txt&path=storage1/b.txt")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertRegex(
|
||||||
|
response.headers.get("content-disposition", ""),
|
||||||
|
r'attachment; filename="kodidownload-\d{8}-\d{6}\.zip"',
|
||||||
|
)
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
self.assertIn("a.txt", archive.namelist())
|
||||||
|
self.assertIn("b.txt", archive.namelist())
|
||||||
|
self.assertEqual(archive.read("a.txt"), b"A")
|
||||||
|
self.assertEqual(archive.read("b.txt"), b"B")
|
||||||
|
|
||||||
|
def test_download_multi_directory_selection_as_zip(self) -> None:
|
||||||
|
(self.root / "dir1" / "sub").mkdir(parents=True)
|
||||||
|
(self.root / "dir2").mkdir()
|
||||||
|
(self.root / "dir1" / "sub" / "a.txt").write_text("A", encoding="utf-8")
|
||||||
|
(self.root / "dir2" / "b.txt").write_text("B", encoding="utf-8")
|
||||||
|
|
||||||
|
response = self._get("/api/files/download?path=storage1/dir1&path=storage1/dir2")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertRegex(
|
||||||
|
response.headers.get("content-disposition", ""),
|
||||||
|
r'attachment; filename="kodidownload-\d{8}-\d{6}\.zip"',
|
||||||
|
)
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
self.assertIn("dir1/", archive.namelist())
|
||||||
|
self.assertIn("dir1/sub/", archive.namelist())
|
||||||
|
self.assertIn("dir1/sub/a.txt", archive.namelist())
|
||||||
|
self.assertIn("dir2/b.txt", archive.namelist())
|
||||||
|
|
||||||
|
def test_download_mixed_file_and_directory_selection_as_zip(self) -> None:
|
||||||
|
(self.root / "readme.txt").write_text("R", encoding="utf-8")
|
||||||
|
(self.root / "photos" / "nested").mkdir(parents=True)
|
||||||
|
(self.root / "photos" / "nested" / "img.txt").write_text("P", encoding="utf-8")
|
||||||
|
|
||||||
|
response = self._get("/api/files/download?path=storage1/readme.txt&path=storage1/photos")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertRegex(
|
||||||
|
response.headers.get("content-disposition", ""),
|
||||||
|
r'attachment; filename="kodidownload-\d{8}-\d{6}\.zip"',
|
||||||
|
)
|
||||||
|
with zipfile.ZipFile(BytesIO(response.content)) as archive:
|
||||||
|
self.assertIn("readme.txt", archive.namelist())
|
||||||
|
self.assertIn("photos/", archive.namelist())
|
||||||
|
self.assertIn("photos/nested/img.txt", archive.namelist())
|
||||||
|
|
||||||
|
def test_download_directory_with_symlink_rejected(self) -> None:
|
||||||
|
target = self.root / "real.txt"
|
||||||
|
target.write_text("x", encoding="utf-8")
|
||||||
|
(self.root / "docs").mkdir()
|
||||||
|
(self.root / "docs" / "link.txt").symlink_to(target)
|
||||||
|
|
||||||
response = self._get("/api/files/download?path=storage1/docs")
|
response = self._get("/api/files/download?path=storage1/docs")
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('function openContextMenu(pane, entry, event)', app_js)
|
self.assertIn('function openContextMenu(pane, entry, event)', app_js)
|
||||||
self.assertIn('function closeContextMenu()', app_js)
|
self.assertIn('function closeContextMenu()', app_js)
|
||||||
self.assertIn('function isOpenableSelection(item)', app_js)
|
self.assertIn('function isOpenableSelection(item)', app_js)
|
||||||
self.assertIn('async function downloadFileRequest(path)', app_js)
|
self.assertIn('async function downloadFileRequest(paths)', app_js)
|
||||||
self.assertIn('function applyContextMenuSelection()', app_js)
|
self.assertIn('function applyContextMenuSelection()', app_js)
|
||||||
self.assertIn('function startContextMenuOpen()', app_js)
|
self.assertIn('function startContextMenuOpen()', app_js)
|
||||||
self.assertIn('function startContextMenuEdit()', app_js)
|
self.assertIn('function startContextMenuEdit()', app_js)
|
||||||
@@ -239,9 +239,9 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('const editableSingle = items.length === 1 && isEditableSelection(items[0]);', app_js)
|
self.assertIn('const editableSingle = items.length === 1 && isEditableSelection(items[0]);', app_js)
|
||||||
self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");', app_js)
|
self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");', app_js)
|
||||||
self.assertIn('elements.editButton.disabled = !editableSingle;', app_js)
|
self.assertIn('elements.editButton.disabled = !editableSingle;', app_js)
|
||||||
self.assertIn('const downloadableSingle = items.length === 1 && items[0].kind === "file";', app_js)
|
self.assertIn('const downloadableSelection = items.length > 0;', app_js)
|
||||||
self.assertIn('elements.downloadButton.classList.toggle("hidden", !downloadableSingle);', app_js)
|
self.assertIn('elements.downloadButton.classList.remove("hidden");', app_js)
|
||||||
self.assertIn('elements.downloadButton.disabled = !downloadableSingle;', app_js)
|
self.assertIn('elements.downloadButton.disabled = !downloadableSelection;', app_js)
|
||||||
self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js)
|
self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js)
|
||||||
self.assertIn('elements.copyButton.classList.remove("hidden");', app_js)
|
self.assertIn('elements.copyButton.classList.remove("hidden");', app_js)
|
||||||
self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js)
|
self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js)
|
||||||
@@ -250,8 +250,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js)
|
self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js)
|
||||||
self.assertIn('openCurrentDirectory();', app_js)
|
self.assertIn('openCurrentDirectory();', app_js)
|
||||||
self.assertIn('openEditor();', app_js)
|
self.assertIn('openEditor();', app_js)
|
||||||
self.assertIn('downloadFileRequest(selected.path);', app_js)
|
self.assertIn('downloadFileRequest(selectedItems.map((item) => item.path));', app_js)
|
||||||
self.assertIn('anchor.download = selected.name;', app_js)
|
self.assertIn('anchor.download = fileName || selected.name;', app_js)
|
||||||
self.assertIn('openRenamePopup();', app_js)
|
self.assertIn('openRenamePopup();', app_js)
|
||||||
self.assertIn('startCopySelected();', app_js)
|
self.assertIn('startCopySelected();', app_js)
|
||||||
self.assertIn('openF6Flow();', app_js)
|
self.assertIn('openF6Flow();', app_js)
|
||||||
|
|||||||
+20
-11
@@ -384,15 +384,15 @@ function openContextMenu(pane, entry, event) {
|
|||||||
const isMulti = items.length > 1;
|
const isMulti = items.length > 1;
|
||||||
const openableSingle = items.length === 1 && isOpenableSelection(items[0]);
|
const openableSingle = items.length === 1 && isOpenableSelection(items[0]);
|
||||||
const editableSingle = items.length === 1 && isEditableSelection(items[0]);
|
const editableSingle = items.length === 1 && isEditableSelection(items[0]);
|
||||||
const downloadableSingle = items.length === 1 && items[0].kind === "file";
|
const downloadableSelection = items.length > 0;
|
||||||
elements.scope.textContent = isMulti ? "Multi-selection" : "Single item";
|
elements.scope.textContent = isMulti ? "Multi-selection" : "Single item";
|
||||||
elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name;
|
elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name;
|
||||||
elements.openButton.classList.toggle("hidden", isMulti);
|
elements.openButton.classList.toggle("hidden", isMulti);
|
||||||
elements.openButton.disabled = !openableSingle;
|
elements.openButton.disabled = !openableSingle;
|
||||||
elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");
|
elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");
|
||||||
elements.editButton.disabled = !editableSingle;
|
elements.editButton.disabled = !editableSingle;
|
||||||
elements.downloadButton.classList.toggle("hidden", !downloadableSingle);
|
elements.downloadButton.classList.remove("hidden");
|
||||||
elements.downloadButton.disabled = !downloadableSingle;
|
elements.downloadButton.disabled = !downloadableSelection;
|
||||||
elements.renameButton.classList.toggle("hidden", isMulti);
|
elements.renameButton.classList.toggle("hidden", isMulti);
|
||||||
elements.copyButton.classList.remove("hidden");
|
elements.copyButton.classList.remove("hidden");
|
||||||
elements.copyButton.disabled = items.length === 0;
|
elements.copyButton.disabled = items.length === 0;
|
||||||
@@ -496,21 +496,21 @@ function startContextMenuEdit() {
|
|||||||
|
|
||||||
async function startDownloadSelected() {
|
async function startDownloadSelected() {
|
||||||
const selectedItems = activePaneState().selectedItems;
|
const selectedItems = activePaneState().selectedItems;
|
||||||
if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") {
|
if (selectedItems.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selected = selectedItems[0];
|
|
||||||
try {
|
try {
|
||||||
const blob = await downloadFileRequest(selected.path);
|
const selected = selectedItems[0];
|
||||||
|
const { blob, fileName } = await downloadFileRequest(selectedItems.map((item) => item.path));
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement("a");
|
const anchor = document.createElement("a");
|
||||||
anchor.href = url;
|
anchor.href = url;
|
||||||
anchor.download = selected.name;
|
anchor.download = fileName || selected.name;
|
||||||
document.body.append(anchor);
|
document.body.append(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
anchor.remove();
|
anchor.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
setStatus(`Download started: ${selected.name}`);
|
setStatus(`Download started: ${anchor.download}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError("Download", err);
|
setActionError("Download", err);
|
||||||
}
|
}
|
||||||
@@ -782,13 +782,22 @@ function createApiError(response, data) {
|
|||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFileRequest(path) {
|
async function downloadFileRequest(paths) {
|
||||||
const response = await fetch(`/api/files/download?${new URLSearchParams({ path }).toString()}`);
|
const params = new URLSearchParams();
|
||||||
|
for (const path of paths) {
|
||||||
|
params.append("path", path);
|
||||||
|
}
|
||||||
|
const response = await fetch(`/api/files/download?${params.toString()}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
throw createApiError(response, data);
|
throw createApiError(response, data);
|
||||||
}
|
}
|
||||||
return response.blob();
|
const disposition = response.headers.get("content-disposition") || "";
|
||||||
|
const match = disposition.match(/filename=\"([^\"]+)\"/);
|
||||||
|
return {
|
||||||
|
blob: await response.blob(),
|
||||||
|
fileName: match ? match[1] : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFileRequest(targetPath, file, overwrite = false) {
|
async function uploadFileRequest(targetPath, file, overwrite = false) {
|
||||||
|
|||||||
Reference in New Issue
Block a user