feat(files): add folder download as zip

- Add zip folder API endpoint
- Add download button to folder rows
- Enforce max archive size limit
- Document endpoint in OpenAPI
This commit is contained in:
2026-09-01 20:20:15 +03:00
parent 1bba2c6b51
commit e7982c5487
7 changed files with 288 additions and 9 deletions
+1
View File
@@ -84,6 +84,7 @@ func (a *Agent) Serve() error {
mux.HandleFunc("/openapi.json", a.handleOpenAPI)
mux.HandleFunc("/api/v1/status", a.handleStatus)
mux.HandleFunc("/api/v1/files", a.handleFiles)
mux.HandleFunc("/api/v1/files/zip", a.handleZipFolder)
mux.HandleFunc("/api/v1/download", a.handleDownload)
mux.HandleFunc("/api/v1/upload", a.handleUpload)
mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot)
+56
View File
@@ -135,6 +135,62 @@ func (a *Agent) deleteFile(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "path": path})
}
func (a *Agent) handleZipFolder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
path := r.URL.Query().Get("path")
if path == "" {
helpers.WriteError(w, http.StatusBadRequest, "path is required")
return
}
dir, err := files.AllowedPath(a.root, path, true)
if err != nil {
files.WritePathError(w, err)
return
}
info, err := os.Stat(dir)
if err != nil {
files.WritePathError(w, err)
return
}
if !info.IsDir() {
helpers.WriteError(w, http.StatusBadRequest, "path is not a directory")
return
}
zipPath, err := files.ZipDirectory(dir, config.MaxUploadSize)
if err != nil {
switch {
case errors.Is(err, files.ErrZipTooLarge):
helpers.WriteError(w, http.StatusRequestEntityTooLarge, err.Error())
default:
helpers.Log.Printf("zip folder: %v", err)
files.WritePathError(w, err)
}
return
}
defer os.Remove(zipPath)
f, err := os.Open(zipPath)
if err != nil {
helpers.Log.Printf("zip open: %v", err)
helpers.WriteError(w, http.StatusInternalServerError, "could not read archive")
return
}
defer f.Close()
zipInfo, err := f.Stat()
if err != nil {
helpers.WriteError(w, http.StatusInternalServerError, "could not read archive")
return
}
name := files.ZipArchiveName(dir)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(name, `"`, "'")+`"`)
http.ServeContent(w, r, name, zipInfo.ModTime(), f)
}
func (a *Agent) handleDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
+33 -9
View File
@@ -162,6 +162,15 @@
return res.json();
}
function downloadFolder(fullPath, folderName) {
const link = document.createElement("a");
link.href = `/api/v1/files/zip?path=${encodeURIComponent(fullPath)}`;
link.download = `${folderName}.zip`;
document.body.append(link);
link.click();
link.remove();
}
async function deleteFile(fullPath, name) {
if (!confirm(`Delete ${name}?`)) {
return;
@@ -184,21 +193,37 @@
for (const entry of entries) {
const tr = document.createElement("tr");
const name = document.createElement("td");
name.className = entry.type === "dir" ? "name" : "name file";
name.textContent = entry.name;
name.addEventListener("click", () => {
const full = joinPath(data.path, entry.name);
if (entry.type === "dir") {
const full = joinPath(data.path, entry.name);
if (entry.type === "dir") {
name.className = "name dir";
const label = document.createElement("span");
label.className = "dir-label";
label.textContent = entry.name;
label.addEventListener("click", () => {
listFiles(full).catch((err) => showError(err.message));
} else {
});
const zipBtn = document.createElement("button");
zipBtn.type = "button";
zipBtn.className = "folder-zip";
zipBtn.textContent = "Download";
zipBtn.title = "Download folder as zip";
zipBtn.addEventListener("click", (event) => {
event.stopPropagation();
downloadFolder(full, entry.name);
});
name.append(label, zipBtn);
} else {
name.className = "name file";
name.textContent = entry.name;
name.addEventListener("click", () => {
const link = document.createElement("a");
link.href = `/api/v1/download?path=${encodeURIComponent(full)}`;
link.download = entry.name;
document.body.append(link);
link.click();
link.remove();
}
});
});
}
const type = document.createElement("td");
type.textContent = entry.type;
const size = document.createElement("td");
@@ -207,7 +232,6 @@
modified.textContent = entry.modified_time ? new Date(entry.modified_time).toLocaleString() : "";
const action = document.createElement("td");
action.className = "actions";
const full = joinPath(data.path, entry.name);
const del = document.createElement("button");
del.type = "button";
del.className = "danger";
+14
View File
@@ -543,6 +543,20 @@
tbody tr:hover td { background: rgba(94, 234, 212, 0.04); }
tbody tr:last-child td { border-bottom: 0; }
td.name { cursor: pointer; color: var(--accent); font-weight: 500; }
td.name.dir { display: flex; align-items: center; gap: 0.5rem; }
td.name .dir-label { flex: 1; min-width: 0; }
td.name .folder-zip {
flex-shrink: 0;
padding: 0.15rem 0.45rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-muted);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
td.name .folder-zip:hover { color: var(--accent-bright); border-color: var(--accent-dim); }
td.name.file { color: var(--text); font-weight: 400; }
td.name.file:hover { color: var(--accent-bright); }
td.actions { white-space: nowrap; text-align: right; }