From 87fb2b4a1f09b72e568d9a90c2cf664ac1c31471 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 26 Jun 2026 01:03:12 +0000 Subject: [PATCH] files: make ~member browsable before first session A valid member's ~name 404'd until they'd opened their first SFTP/web session, because joining onto a not-yet-created site dir tripped the safeJoin escape guard (it walks up to sites/, outside the per-user root). AnonRoot now materializes the idempotent site dir for a known member, so ~name renders an empty listing the moment the account exists. Missing sub-paths and unknown members still 404. Adds a regression test. Co-Authored-By: Claude Opus 4.8 --- internal/files/backend.go | 6 ++++++ internal/files/web.go | 11 ++++++++++- internal/files/web_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/files/backend.go b/internal/files/backend.go index ca4c486..803755c 100644 --- a/internal/files/backend.go +++ b/internal/files/backend.go @@ -229,6 +229,12 @@ func (s *Service) AnonRoot(name string) (root string, ok bool, err error) { if !found || u.Banned { return "", false, nil } + // Materialize the (idempotent) site dir so ~name is browsable the moment the + // account exists — before the member's first SFTP/web session creates it. + // Without this, joining onto a missing root trips the escape guard. + if err := s.ensureSite(u.Name); err != nil { + return "", false, err + } return s.siteRoot(u.Name), true, nil } diff --git a/internal/files/web.go b/internal/files/web.go index 612f9c8..2eea08a 100644 --- a/internal/files/web.go +++ b/internal/files/web.go @@ -386,6 +386,14 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) { } fi, err := os.Stat(real) if err != nil { + // A known member whose site dir hasn't been created yet (it is created + // lazily on their first files session) renders as an empty listing — not + // a 404 — so ~name is reachable as soon as the account exists. A missing + // sub-path still 404s. + if os.IsNotExist(err) && path.Clean("/"+strings.TrimPrefix(rel, "/")) == "/" { + h.renderAnonDir(w, prefix, heading, rel, real) + return + } http.NotFound(w, r) return } @@ -408,10 +416,11 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) { // on-disk directory (already confined by handleAnon). func (h *webSrv) renderAnonDir(w http.ResponseWriter, prefix, heading, rel, real string) { des, err := os.ReadDir(real) - if err != nil { + if err != nil && !os.IsNotExist(err) { http.Error(w, "cannot list files", http.StatusInternalServerError) return } + // A not-yet-created site dir lists as empty (des is nil). rel = path.Clean("/" + strings.TrimPrefix(rel, "/")) data := anonData{Title: h.cfg.Title, CurPath: heading} if rel != "/" { diff --git a/internal/files/web_test.go b/internal/files/web_test.go index cf4757a..99db026 100644 --- a/internal/files/web_test.go +++ b/internal/files/web_test.go @@ -195,6 +195,38 @@ func TestWebAnonPublicSite(t *testing.T) { } } +func TestWebAnonMemberSiteEmptyNot404(t *testing.T) { + // A registered member who has not published anything yet (no site dir on + // disk) is reachable at ~name as an empty listing, not a 404. A missing file + // under them, and an unknown member, both still 404. + svc, st, _ := newTestService(t) + if _, err := st.EnsureUser("bob", "member", "SHA256:bobkey"); err != nil { + t.Fatal(err) + } + h := svc.WebHandler(WebConfig{Title: "files.test"}) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("~bob (member, empty site): want 200, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "(empty)") { + t.Fatalf("~bob should render an empty listing: %.200s", rr.Body.String()) + } + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/nope.txt", nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("~bob/nope.txt: want 404, got %d", rr.Code) + } + + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~nobody/", nil)) + if rr.Code != http.StatusNotFound { + t.Fatalf("~nobody (unknown): want 404, got %d", rr.Code) + } +} + func TestWebAnonCannotEscape(t *testing.T) { h, _ := webTestHandler(t) cookie := loginCookie(t, h)