Skip to content

Commit b18c151

Browse files
committed
feat: ZIP archive download endpoint (POST /files/archive)
Add a single POST /files/archive endpoint that bundles one or more files and directories into a ZIP archive for download. - Accepts a list of paths (files and/or directories) - Directories are recursively included with relative paths preserved - Multi-user access control enforced via UserFS - Cross-platform ZIP format (Windows, macOS, Linux) - Archive name derived from input (single item = item name, multiple = download.zip) Closes #92
1 parent 61f50c8 commit b18c151

File tree

3 files changed

+74
-1
lines changed

3 files changed

+74
-1
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [0.11.28] - 2026-03-24
8+
9+
### Added
10+
11+
- 📦 **Compressed directory download** (`POST /files/archive`) — bundle one or more files and directories into a ZIP archive for download. Cross-platform compatible (Windows, macOS, Linux). Multi-user access control enforced.
12+
713
## [0.11.27] - 2026-03-22
814

915
### Added

open_terminal/main.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,73 @@ async def upload_file(
922922
return {"path": path, "size": len(content)}
923923

924924

925+
class ArchiveRequest(BaseModel):
926+
paths: list[str] = Field(
927+
...,
928+
description="List of file or directory paths to include in the ZIP archive.",
929+
)
930+
931+
932+
@app.post(
933+
"/files/archive",
934+
include_in_schema=False,
935+
dependencies=[Depends(verify_api_key)],
936+
)
937+
async def archive_paths(
938+
request: ArchiveRequest,
939+
fs: UserFS = Depends(get_filesystem),
940+
):
941+
"""Bundle files and/or directories into a single ZIP archive."""
942+
import io
943+
import zipfile
944+
945+
if not request.paths:
946+
raise HTTPException(status_code=400, detail="No paths provided")
947+
948+
resolved = []
949+
for p in request.paths:
950+
target = fs.resolve_path(p)
951+
if not await fs.exists(target):
952+
raise HTTPException(status_code=404, detail=f"Path not found: {p}")
953+
resolved.append(target)
954+
955+
# Derive a meaningful archive name from the input paths.
956+
if len(resolved) == 1:
957+
archive_name = os.path.basename(resolved[0].rstrip("/\\")) or "archive"
958+
else:
959+
archive_name = "download"
960+
961+
def _build_zip() -> bytes:
962+
buf = io.BytesIO()
963+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
964+
for target in resolved:
965+
if os.path.isfile(target):
966+
zf.write(target, os.path.basename(target))
967+
elif os.path.isdir(target):
968+
dirname = os.path.basename(target.rstrip("/\\")) or "dir"
969+
for dirpath, dirnames, filenames in os.walk(target):
970+
dirnames[:] = [
971+
d for d in dirnames
972+
if fs.is_path_allowed(os.path.join(dirpath, d))
973+
]
974+
for fname in filenames:
975+
full = os.path.join(dirpath, fname)
976+
if not fs.is_path_allowed(full):
977+
continue
978+
arcname = os.path.join(
979+
dirname, os.path.relpath(full, target)
980+
)
981+
zf.write(full, arcname)
982+
return buf.getvalue()
983+
984+
data = await asyncio.to_thread(_build_zip)
985+
return Response(
986+
content=data,
987+
media_type="application/zip",
988+
headers={
989+
"Content-Disposition": f'attachment; filename="{archive_name}.zip"',
990+
},
991+
)
925992

926993

927994
# ---------------------------------------------------------------------------

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "open-terminal"
3-
version = "0.11.27"
3+
version = "0.11.28"
44
description = "A remote terminal API."
55
readme = "README.md"
66
authors = [

0 commit comments

Comments
 (0)