From be7574424834835a1e663f26abce953526e4b2a1 Mon Sep 17 00:00:00 2001 From: RissRIce Date: Wed, 12 Aug 2026 20:20:06 -0600 Subject: [PATCH] fix(gopher): serve public files from SFTP storage (#115) * test(gopher): cover SFTP public file paths * fix(gopher): serve public files from SFTP storage --- internal/gopher/gopher_test.go | 29 +++++++++++++++++++++++++++++ internal/gopher/server.go | 8 ++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/gopher/gopher_test.go b/internal/gopher/gopher_test.go index f597cb3..c020a15 100644 --- a/internal/gopher/gopher_test.go +++ b/internal/gopher/gopher_test.go @@ -159,6 +159,35 @@ func TestHomepageFileAndDir(t *testing.T) { } } +func TestPublicFilesUseSFTPStorageRoot(t *testing.T) { + srv, dataDir := newTestServer(t) + + publicRoot := filepath.Join(dataDir, "files", "public", "alice") + mustMkdir(t, publicRoot) + mustWrite(t, filepath.Join(publicRoot, "shared.txt"), "uploaded through sftp") + + legacyRoot := filepath.Join(dataDir, "users", "alice", "public") + mustMkdir(t, legacyRoot) + mustWrite(t, filepath.Join(legacyRoot, "legacy.txt"), "stale location") + + dir := srv.Resolve("/files/~alice", false, "") + if dir.Kind != KindMenu { + t.Fatalf("public files root should be a dir menu, got %v", dir.Kind) + } + wire := string(dir.Wire()) + if !strings.Contains(wire, "/files/~alice/shared.txt") { + t.Errorf("public files should list SFTP uploads:\n%s", wire) + } + if strings.Contains(wire, "legacy.txt") { + t.Errorf("public files must not read the legacy user directory:\n%s", wire) + } + + file := srv.Resolve("/files/~alice/shared.txt", false, "") + if file.Kind != KindText || file.Text != "uploaded through sftp" { + t.Errorf("SFTP public file should be served as text: %+v", file) + } +} + func TestPathTraversalRefused(t *testing.T) { srv, _ := newTestServer(t) for _, sel := range []string{ diff --git a/internal/gopher/server.go b/internal/gopher/server.go index 170ccb7..2159f18 100644 --- a/internal/gopher/server.go +++ b/internal/gopher/server.go @@ -262,7 +262,7 @@ func (s *Server) newsArticles(group string) Response { } // filesRoot lists every non-banned member, each linking to their public files -// area (/users//public), mirroring the anonymous web files surface. +// area (/files/public/), mirroring the anonymous web files surface. func (s *Server) filesRoot() Response { users, err := s.c.ListUsers(1000) if err != nil { @@ -287,7 +287,8 @@ func (s *Server) filesRoot() Response { // userTree serves a member's per-user area (public_html homepage, or the public // files area) as gopher content. prefix is the selector root ("" for homepages, // "files" for the files area); sel is the remainder after the prefix, of the form -// "~name[/subpath]". area is the on-disk subdirectory under /users/. +// "~name[/subpath]". Homepages live under /users//; public +// files share the SFTP service's /files/public/ storage root. // // The subpath is confined to the member's area: it is cleaned as an absolute // path (so any ".." that would escape is neutralised) and the resolved target is @@ -301,6 +302,9 @@ func (s *Server) userTree(prefix, sel, area string) Response { return errResp("bad member name") } base := filepath.Join(s.dataDir, "users", name, area) + if prefix == "files" { + base = filepath.Join(s.dataDir, "files", "public", name) + } // Confine sub to base. Cleaning as an absolute path drops any leading ".." // components; the HasPrefix re-check is belt-and-suspenders against edge