diff --git a/lib/agent/agent.go b/lib/agent/agent.go index 2fa9cdf..37968fb 100644 --- a/lib/agent/agent.go +++ b/lib/agent/agent.go @@ -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) diff --git a/lib/agent/handlers.go b/lib/agent/handlers.go index 16e65d2..bf50c62 100644 --- a/lib/agent/handlers.go +++ b/lib/agent/handlers.go @@ -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") diff --git a/lib/agent/web/app.js b/lib/agent/web/app.js index 40fa20f..e7e330a 100644 --- a/lib/agent/web/app.js +++ b/lib/agent/web/app.js @@ -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"; diff --git a/lib/agent/web/index.html b/lib/agent/web/index.html index 73b47a9..faf4f4c 100644 --- a/lib/agent/web/index.html +++ b/lib/agent/web/index.html @@ -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; } diff --git a/lib/files/zip.go b/lib/files/zip.go new file mode 100644 index 0000000..b845efa --- /dev/null +++ b/lib/files/zip.go @@ -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) +} diff --git a/lib/files/zip_test.go b/lib/files/zip_test.go new file mode 100644 index 0000000..a217f09 --- /dev/null +++ b/lib/files/zip_test.go @@ -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) + } +} diff --git a/lib/openapi/spec.go b/lib/openapi/spec.go index 0a942bd..7a07500 100644 --- a/lib/openapi/spec.go +++ b/lib/openapi/spec.go @@ -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{ "get": map[string]any{ "summary": "Download a file",