From beb6b9040eade404671602a0b8eb412e5e976ff9 Mon Sep 17 00:00:00 2001 From: 28allday Date: Wed, 27 May 2026 21:00:52 +0100 Subject: [PATCH] Support sending folders, not just single files The sender only handled regular files: a staged directory would fail at upload time, so users could only send individual files. Now a staged directory is walked recursively and each file is advertised with a name relative to the folder's parent (e.g. "Trip/day1/img.jpg"), which is the LocalSend-compatible way to carry structure. The receiver recreates those subdirectories under the receive dir, creating parents as needed. The path-traversal guard is preserved: names are cleaned against a leading "/" to collapse "..", and a containment check ensures the result stays within the receive dir. In the TUI send picker, "a" stages the folder currently being browsed; staged folders are tagged in the panel and the help text is updated. Tests: directory expansion produces relative names; an end-to-end folder send recreates the structure on the receiver; destPath preserves subdirs, rejects traversal, and de-duplicates within subfolders. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 7 +- internal/client/client.go | 76 +++++++++++++++---- internal/client/folder_test.go | 124 +++++++++++++++++++++++++++++++ internal/server/destpath_test.go | 64 ++++++++++++++++ internal/server/server.go | 45 +++++++---- internal/tui/model.go | 17 ++++- 6 files changed, 301 insertions(+), 32 deletions(-) create mode 100644 internal/client/folder_test.go create mode 100644 internal/server/destpath_test.go diff --git a/README.md b/README.md index 68901ac..90ccfc4 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ LocalSend mobile and desktop apps on the same LAN, including their default `/register` handshake, with peer aging. - **Receive** — incoming files are accepted via a prompt (or auto-accepted) and written to the receive directory, with live progress. -- **Send** — pick a peer, stage files with a built-in file picker, and upload - them with progress, rate and ETA. +- **Send** — pick a peer, stage files (or whole folders) with a built-in file + picker, and upload them with progress, rate and ETA. Folders are sent + recursively with their structure preserved on the receiver. - **Manage** — browse the receive folder, mark received files (or whole folders) and delete the ones you no longer want, behind a confirmation prompt. - **HTTPS** — generates a self-signed certificate whose fingerprint matches the @@ -92,7 +93,7 @@ omarchy-send --auto-accept --pin 2468 - `1`/`2`/`3`/`4` or `tab` — switch between Devices / Transfers / Manage / Settings - Peers: `enter` send to the selected peer · `r` refresh · `/` filter -- Send picker: `enter` stage a file · `backspace` unstage · `S` send · `esc` back +- Send picker: `enter` stage a file · `a` add the current folder · `backspace` unstage · `S` send · `esc` back - Incoming prompt: `y` accept · `n` reject - Transfers: `c` clear finished - Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one diff --git a/internal/client/client.go b/internal/client/client.go index 88204ec..040f965 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "mime" "net" "net/http" @@ -72,23 +73,21 @@ func (s *Sender) Send(peer discovery.Peer, paths []string, pin string) { } func (s *Sender) send(peer discovery.Peer, paths []string, pin string) { - // Build file metadata keyed by a generated fileId. - files := make(map[string]protocol.FileMetadata, len(paths)) - pathByID := make(map[string]string, len(paths)) - for _, p := range paths { - fi, err := os.Stat(p) - if err != nil { - s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(p), Err: err}) - continue - } + // Expand any directories into their files, then build metadata keyed by a + // generated fileId. A directory's files carry a relative FileName (e.g. + // "Trip/day1/img.jpg") so the receiver can recreate the folder structure. + items := s.expand(paths) + files := make(map[string]protocol.FileMetadata, len(items)) + pathByID := make(map[string]string, len(items)) + for _, it := range items { id := randID() files[id] = protocol.FileMetadata{ ID: id, - FileName: filepath.Base(p), - Size: fi.Size(), - FileType: mimeType(p), + FileName: it.name, + Size: it.size, + FileType: mimeType(it.path), } - pathByID[id] = p + pathByID[id] = it.path } if len(files) == 0 { return @@ -124,6 +123,57 @@ func (s *Sender) send(peer discovery.Peer, paths []string, pin string) { } } +// fileItem is one concrete file to upload: its path on disk, the relative name +// advertised to the peer (carries folder structure), and its size. +type fileItem struct { + path string + name string + size int64 +} + +// expand turns the selected paths into a flat list of files. A regular file is +// passed through with its base name. A directory is walked recursively; each +// contained file's advertised name is its path relative to the directory's +// parent, so the selected folder itself is recreated on the receiver (selecting +// "Trip" yields "Trip/day1/img.jpg", …). Unreadable entries are skipped with an +// error event rather than aborting the whole transfer. +func (s *Sender) expand(paths []string) []fileItem { + var items []fileItem + for _, p := range paths { + fi, err := os.Stat(p) + if err != nil { + s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(p), Err: err}) + continue + } + if !fi.IsDir() { + items = append(items, fileItem{path: p, name: filepath.Base(p), size: fi.Size()}) + continue + } + root := filepath.Dir(filepath.Clean(p)) // parent, so the folder name is kept + _ = filepath.WalkDir(p, func(fp string, d fs.DirEntry, err error) error { + if err != nil { + s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(fp), Err: err}) + return nil // skip this entry, keep walking the rest + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: filepath.Base(fp), Err: err}) + return nil + } + rel, err := filepath.Rel(root, fp) + if err != nil { + rel = filepath.Base(fp) + } + items = append(items, fileItem{path: fp, name: filepath.ToSlash(rel), size: info.Size()}) + return nil + }) + } + return items +} + func (s *Sender) prepareUpload(base string, files map[string]protocol.FileMetadata, pin string) (protocol.PrepareUploadResponse, error) { reqBody, _ := json.Marshal(protocol.PrepareUploadRequest{Info: s.selfCopy(), Files: files}) url := base + protocol.PathPrepareUpload diff --git a/internal/client/folder_test.go b/internal/client/folder_test.go new file mode 100644 index 0000000..7714f43 --- /dev/null +++ b/internal/client/folder_test.go @@ -0,0 +1,124 @@ +package client + +import ( + "context" + "os" + "path/filepath" + "sort" + "testing" + "time" + + "omarchy-send/internal/discovery" + "omarchy-send/internal/protocol" + "omarchy-send/internal/server" + "omarchy-send/internal/transfer" +) + +// expand must walk a directory and advertise each file with a name relative to +// the selected folder's parent, so the folder itself is part of the path. +func TestExpandDirectory(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, "Trip") + mustWrite(t, filepath.Join(dir, "cover.jpg"), "a") + mustWrite(t, filepath.Join(dir, "day1", "img.jpg"), "bb") + mustWrite(t, filepath.Join(dir, "day1", "notes.txt"), "ccc") + + s := New(protocol.DeviceInfo{}) + items := s.expand([]string{dir}) + + got := make([]string, len(items)) + for i, it := range items { + got[i] = it.name + } + sort.Strings(got) + want := []string{"Trip/cover.jpg", "Trip/day1/img.jpg", "Trip/day1/notes.txt"} + if len(got) != len(want) { + t.Fatalf("expand returned %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expand[%d] = %q, want %q (all: %v)", i, got[i], want[i], got) + } + } +} + +// A regular file path passes through with just its base name. +func TestExpandFile(t *testing.T) { + p := filepath.Join(t.TempDir(), "single.bin") + mustWrite(t, p, "x") + items := New(protocol.DeviceInfo{}).expand([]string{p}) + if len(items) != 1 || items[0].name != "single.bin" { + t.Fatalf("expand file = %+v", items) + } +} + +// End-to-end: sending a directory recreates its structure on the receiver. +func TestSendDirectoryPreservesStructure(t *testing.T) { + recvDir := t.TempDir() + recvInfo := protocol.DeviceInfo{ + Alias: "recv", Version: protocol.ProtocolVersion, Port: 53998, Protocol: "http", + } + srv := server.New(server.Options{Info: recvInfo, ReceiveDir: recvDir, AutoAccept: true}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := srv.Start(ctx); err != nil { + t.Fatalf("server start: %v", err) + } + go func() { + for range srv.Transfers() { + } + }() + time.Sleep(50 * time.Millisecond) + + srcRoot := t.TempDir() + dir := filepath.Join(srcRoot, "Trip") + files := map[string]string{ + "cover.jpg": "cover-bytes", + "day1/img.jpg": "day1-image", + "day2/clip.mov": "day2-clip", + "day2/sub/note.md": "nested-note", + } + for rel, body := range files { + mustWrite(t, filepath.Join(dir, filepath.FromSlash(rel)), body) + } + + sender := New(protocol.DeviceInfo{Alias: "sender", Fingerprint: "snd1", Version: "2.1", Protocol: "http"}) + peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"} + sender.Send(peer, []string{dir}, "") + + done := 0 + deadline := time.After(5 * time.Second) + for done < len(files) { + select { + case ev := <-sender.Events(): + if ev.Kind == transfer.Error { + t.Fatalf("send error: %v", ev.Err) + } + if ev.Kind == transfer.FileDone { + done++ + } + case <-deadline: + t.Fatalf("timed out after %d/%d files", done, len(files)) + } + } + + for rel, body := range files { + got, err := os.ReadFile(filepath.Join(recvDir, "Trip", filepath.FromSlash(rel))) + if err != nil { + t.Fatalf("missing received %q: %v", rel, err) + } + if string(got) != body { + t.Fatalf("%q content = %q, want %q", rel, got, body) + } + } +} + +func mustWrite(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/server/destpath_test.go b/internal/server/destpath_test.go new file mode 100644 index 0000000..119caef --- /dev/null +++ b/internal/server/destpath_test.go @@ -0,0 +1,64 @@ +package server + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDestPathPreservesSubdirs(t *testing.T) { + dir := t.TempDir() + got, err := destPath(dir, "Trip/day1/img.jpg") + if err != nil { + t.Fatal(err) + } + want := filepath.Join(dir, "Trip", "day1", "img.jpg") + if got != want { + t.Fatalf("destPath = %q, want %q", got, want) + } + // Parent directories must already exist so the writer can create the file. + if _, err := os.Stat(filepath.Dir(got)); err != nil { + t.Fatalf("parent dir not created: %v", err) + } +} + +// Traversal attempts must be neutralised — the result always stays under dir. +func TestDestPathRejectsTraversal(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{ + "../escape.txt", + "../../etc/passwd", + "a/../../../oops.txt", + "/abs/path.txt", + } { + got, err := destPath(dir, name) + if err != nil { + continue // rejected outright — also acceptable + } + if got != dir && !strings.HasPrefix(got, dir+string(os.PathSeparator)) { + t.Fatalf("destPath(%q) = %q escaped %q", name, got, dir) + } + } +} + +// A name colliding with an existing file gets a " (n)" suffix; subdir collisions +// are resolved within their own directory. +func TestDestPathDeDup(t *testing.T) { + dir := t.TempDir() + first, _ := destPath(dir, "sub/file.txt") + if err := os.WriteFile(first, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + second, err := destPath(dir, "sub/file.txt") + if err != nil { + t.Fatal(err) + } + if second == first { + t.Fatalf("expected a de-duplicated path, got %q twice", second) + } + want := filepath.Join(dir, "sub", "file (1).txt") + if second != want { + t.Fatalf("dedup = %q, want %q", second, want) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index c4472a8..a8ca115 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "sync/atomic" "time" @@ -251,10 +252,10 @@ func (s *Server) writeFile(sess *session, fe *fileEntry, key string, r io.Reader s.mu.Lock() dir := s.receiveDir s.mu.Unlock() - if err := os.MkdirAll(dir, 0o755); err != nil { + dest, err := destPath(dir, fe.meta.FileName) + if err != nil { return "", err } - dest := uniquePath(dir, fe.meta.FileName) tmp := dest + ".part" f, err := os.Create(tmp) @@ -297,22 +298,40 @@ func (s *Server) handleCancel(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } -// uniquePath returns a non-colliding path in dir for the (sanitised) filename. -func uniquePath(dir, name string) string { - base := filepath.Base(filepath.Clean("/" + name)) // strip any path components / traversal - if base == "." || base == "/" || base == "" { - base = "file" +// destPath resolves a safe, non-colliding path under dir for the (possibly +// nested) filename, creating parent directories. Sub-paths are honoured so a +// folder send recreates its structure, but any traversal is neutralised: +// cleaning against a leading "/" collapses ".." at the root, and a final +// containment check guarantees the result stays within dir. +func destPath(dir, name string) (string, error) { + rel := strings.TrimPrefix(filepath.Clean("/"+filepath.ToSlash(name)), "/") + if rel == "" || rel == "." { + rel = "file" } - candidate := filepath.Join(dir, base) - if _, err := os.Stat(candidate); os.IsNotExist(err) { - return candidate + full := filepath.Join(dir, filepath.FromSlash(rel)) + if full != dir && !strings.HasPrefix(full, dir+string(os.PathSeparator)) { + return "", fmt.Errorf("unsafe destination for %q", name) } + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return "", err + } + return uniqueAt(full), nil +} + +// uniqueAt returns full if free, otherwise inserts " (n)" before the extension +// until it finds an unused name in the same directory. +func uniqueAt(full string) string { + if _, err := os.Stat(full); os.IsNotExist(err) { + return full + } + d := filepath.Dir(full) + base := filepath.Base(full) ext := filepath.Ext(base) stem := base[:len(base)-len(ext)] for i := 1; ; i++ { - candidate = filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext)) - if _, err := os.Stat(candidate); os.IsNotExist(err) { - return candidate + cand := filepath.Join(d, fmt.Sprintf("%s (%d)%s", stem, i, ext)) + if _, err := os.Stat(cand); os.IsNotExist(err) { + return cand } } } diff --git a/internal/tui/model.go b/internal/tui/model.go index d23ff85..34eb130 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -358,6 +358,13 @@ func (m Model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+c", "q": m.quitting = true return m, tea.Quit + case "a": + // Stage the folder currently being browsed; it is expanded into its + // files (structure preserved) when the transfer starts. + if dir := m.picker.CurrentDirectory; dir != "" && !contains(m.staged, dir) { + m.staged = append(m.staged, dir) + } + return m, nil case "backspace": if len(m.staged) > 0 { m.staged = m.staged[:len(m.staged)-1] @@ -686,12 +693,16 @@ func (m Model) stagedPanel() string { BorderForeground(muted). Padding(0, 1) if len(m.staged) == 0 { - return border.Render(headerStyle.Render("No files staged — press enter on a file to add it.")) + return border.Render(headerStyle.Render("Nothing staged — enter adds a file, a adds the current folder.")) } var b strings.Builder b.WriteString(titleStyle.Render(fmt.Sprintf("Staged · %d", len(m.staged)))) for _, p := range m.staged { - b.WriteString("\n" + valueStyle.Render("• "+collapseHome(p))) + label := collapseHome(p) + if fi, err := os.Stat(p); err == nil && fi.IsDir() { + label += "/ (folder)" + } + b.WriteString("\n" + valueStyle.Render("• "+label)) } return border.Render(b.String()) } @@ -961,7 +972,7 @@ func (m Model) footerText() string { case m.editing: return "tab/↑↓ move · enter next · ctrl+s save · esc cancel" case m.screen == screenPicker: - return "enter stage · backspace unstage · S send · esc back" + return "enter stage file · a add folder · backspace unstage · S send · esc back" case m.screen == screenPeers: return "enter send-to · r refresh · / filter · 1-4 switch · q quit" case m.screen == screenTransfers: