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("/openapi.json", a.handleOpenAPI)
mux.HandleFunc("/api/v1/status", a.handleStatus) mux.HandleFunc("/api/v1/status", a.handleStatus)
mux.HandleFunc("/api/v1/files", a.handleFiles) 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/download", a.handleDownload)
mux.HandleFunc("/api/v1/upload", a.handleUpload) mux.HandleFunc("/api/v1/upload", a.handleUpload)
mux.HandleFunc("/api/v1/screenshot", a.handleScreenshot) 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}) 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) { func (a *Agent) handleDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed") helpers.WriteError(w, http.StatusMethodNotAllowed, "method not allowed")
+33 -9
View File
@@ -162,6 +162,15 @@
return res.json(); 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) { async function deleteFile(fullPath, name) {
if (!confirm(`Delete ${name}?`)) { if (!confirm(`Delete ${name}?`)) {
return; return;
@@ -184,21 +193,37 @@
for (const entry of entries) { for (const entry of entries) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const name = document.createElement("td"); const name = document.createElement("td");
name.className = entry.type === "dir" ? "name" : "name file"; const full = joinPath(data.path, entry.name);
name.textContent = entry.name; if (entry.type === "dir") {
name.addEventListener("click", () => { name.className = "name dir";
const full = joinPath(data.path, entry.name); const label = document.createElement("span");
if (entry.type === "dir") { label.className = "dir-label";
label.textContent = entry.name;
label.addEventListener("click", () => {
listFiles(full).catch((err) => showError(err.message)); 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"); const link = document.createElement("a");
link.href = `/api/v1/download?path=${encodeURIComponent(full)}`; link.href = `/api/v1/download?path=${encodeURIComponent(full)}`;
link.download = entry.name; link.download = entry.name;
document.body.append(link); document.body.append(link);
link.click(); link.click();
link.remove(); link.remove();
} });
}); }
const type = document.createElement("td"); const type = document.createElement("td");
type.textContent = entry.type; type.textContent = entry.type;
const size = document.createElement("td"); const size = document.createElement("td");
@@ -207,7 +232,6 @@
modified.textContent = entry.modified_time ? new Date(entry.modified_time).toLocaleString() : ""; modified.textContent = entry.modified_time ? new Date(entry.modified_time).toLocaleString() : "";
const action = document.createElement("td"); const action = document.createElement("td");
action.className = "actions"; action.className = "actions";
const full = joinPath(data.path, entry.name);
const del = document.createElement("button"); const del = document.createElement("button");
del.type = "button"; del.type = "button";
del.className = "danger"; del.className = "danger";
+14
View File
@@ -543,6 +543,20 @@
tbody tr:hover td { background: rgba(94, 234, 212, 0.04); } tbody tr:hover td { background: rgba(94, 234, 212, 0.04); }
tbody tr:last-child td { border-bottom: 0; } tbody tr:last-child td { border-bottom: 0; }
td.name { cursor: pointer; color: var(--accent); font-weight: 500; } 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 { color: var(--text); font-weight: 400; }
td.name.file:hover { color: var(--accent-bright); } td.name.file:hover { color: var(--accent-bright); }
td.actions { white-space: nowrap; text-align: right; } td.actions { white-space: nowrap; text-align: right; }
+107
View File
@@ -0,0 +1,107 @@
package files
import (
"archive/zip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
var ErrZipTooLarge = errors.New("folder archive exceeds size limit")
// ZipDirectory writes dir into a temp .zip file and returns its path.
func ZipDirectory(dir string, maxBytes int64) (string, error) {
info, err := os.Stat(dir)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", errors.New("path is not a directory")
}
tmp, err := os.CreateTemp("", "win64_mp-zip-*.zip")
if err != nil {
return "", err
}
path := tmp.Name()
cleanup := func() { _ = os.Remove(path) }
zw := zip.NewWriter(tmp)
var written int64
err = filepath.WalkDir(dir, func(current string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.Type()&os.ModeSymlink != 0 {
return nil
}
if !isWithin(dir, current) {
return ErrOutsideRoot
}
rel, err := filepath.Rel(dir, current)
if err != nil {
return err
}
if rel == "." {
return nil
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
if maxBytes > 0 && written+info.Size() > maxBytes {
return ErrZipTooLarge
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
hdr.Name = filepath.ToSlash(rel)
hdr.Method = zip.Deflate
w, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
f, err := os.Open(current)
if err != nil {
return err
}
n, err := io.Copy(w, f)
_ = f.Close()
if err != nil {
return err
}
written += n
return nil
})
if err != nil {
_ = zw.Close()
_ = tmp.Close()
cleanup()
return "", err
}
if err := zw.Close(); err != nil {
_ = tmp.Close()
cleanup()
return "", err
}
if err := tmp.Close(); err != nil {
cleanup()
return "", err
}
return path, nil
}
// ZipArchiveName returns a safe download filename for a directory path.
func ZipArchiveName(dir string) string {
base := filepath.Base(dir)
if base == "" || base == "." || base == string(os.PathSeparator) {
return "folder.zip"
}
return fmt.Sprintf("%s.zip", base)
}
+60
View File
@@ -0,0 +1,60 @@
package files
import (
"archive/zip"
"io"
"os"
"path/filepath"
"testing"
)
func TestZipDirectory(t *testing.T) {
t.Parallel()
root := t.TempDir()
sub := filepath.Join(root, "nested")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("world"), 0o644); err != nil {
t.Fatal(err)
}
zipPath, err := ZipDirectory(root, 1<<20)
if err != nil {
t.Fatal(err)
}
defer os.Remove(zipPath)
r, err := zip.OpenReader(zipPath)
if err != nil {
t.Fatal(err)
}
defer r.Close()
names := make(map[string]string)
for _, f := range r.File {
rc, err := f.Open()
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
t.Fatal(err)
}
names[f.Name] = string(body)
}
if names["a.txt"] != "hello" || names["nested/b.txt"] != "world" {
t.Fatalf("unexpected zip contents: %#v", names)
}
}
func TestZipArchiveName(t *testing.T) {
t.Parallel()
dir := filepath.Join("Users", "dan", "Projects")
if got := ZipArchiveName(dir); got != "Projects.zip" {
t.Fatalf("ZipArchiveName() = %q", got)
}
}
+17
View File
@@ -97,6 +97,23 @@ func Spec() map[string]any {
}), }),
}, },
}, },
"/api/v1/files/zip": map[string]any{
"get": map[string]any{
"summary": "Download a directory as a zip archive",
"operationId": "zipFolder",
"description": "Zips the directory into a temporary file and streams it as an attachment.",
"parameters": []map[string]any{
{"name": "path", "in": "query", "required": true, "schema": map[string]string{"type": "string"}, "description": "Absolute path to the directory"},
},
"responses": auth(map[string]any{
"200": map[string]any{"description": "Zip archive", "content": map[string]any{"application/zip": map[string]any{"schema": map[string]string{"type": "string", "format": "binary"}}}},
"400": errResp("Invalid path or not a directory"),
"403": errResp("Path outside allowed root"),
"404": errResp("Path not found"),
"413": errResp("Archive exceeds size limit"),
}),
},
},
"/api/v1/download": map[string]any{ "/api/v1/download": map[string]any{
"get": map[string]any{ "get": map[string]any{
"summary": "Download a file", "summary": "Download a file",