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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user