Skip to content

Commit 87fb2b4

Browse files
ralyodioclaude
andcommitted
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 <[email protected]>
1 parent 192b117 commit 87fb2b4

3 files changed

Lines changed: 48 additions & 1 deletion

File tree

internal/files/backend.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,12 @@ func (s *Service) AnonRoot(name string) (root string, ok bool, err error) {
229229
if !found || u.Banned {
230230
return "", false, nil
231231
}
232+
// Materialize the (idempotent) site dir so ~name is browsable the moment the
233+
// account exists — before the member's first SFTP/web session creates it.
234+
// Without this, joining onto a missing root trips the escape guard.
235+
if err := s.ensureSite(u.Name); err != nil {
236+
return "", false, err
237+
}
232238
return s.siteRoot(u.Name), true, nil
233239
}
234240

internal/files/web.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,14 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
386386
}
387387
fi, err := os.Stat(real)
388388
if err != nil {
389+
// A known member whose site dir hasn't been created yet (it is created
390+
// lazily on their first files session) renders as an empty listing — not
391+
// a 404 — so ~name is reachable as soon as the account exists. A missing
392+
// sub-path still 404s.
393+
if os.IsNotExist(err) && path.Clean("/"+strings.TrimPrefix(rel, "/")) == "/" {
394+
h.renderAnonDir(w, prefix, heading, rel, real)
395+
return
396+
}
389397
http.NotFound(w, r)
390398
return
391399
}
@@ -408,10 +416,11 @@ func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) {
408416
// on-disk directory (already confined by handleAnon).
409417
func (h *webSrv) renderAnonDir(w http.ResponseWriter, prefix, heading, rel, real string) {
410418
des, err := os.ReadDir(real)
411-
if err != nil {
419+
if err != nil && !os.IsNotExist(err) {
412420
http.Error(w, "cannot list files", http.StatusInternalServerError)
413421
return
414422
}
423+
// A not-yet-created site dir lists as empty (des is nil).
415424
rel = path.Clean("/" + strings.TrimPrefix(rel, "/"))
416425
data := anonData{Title: h.cfg.Title, CurPath: heading}
417426
if rel != "/" {

internal/files/web_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,38 @@ func TestWebAnonPublicSite(t *testing.T) {
195195
}
196196
}
197197

198+
func TestWebAnonMemberSiteEmptyNot404(t *testing.T) {
199+
// A registered member who has not published anything yet (no site dir on
200+
// disk) is reachable at ~name as an empty listing, not a 404. A missing file
201+
// under them, and an unknown member, both still 404.
202+
svc, st, _ := newTestService(t)
203+
if _, err := st.EnsureUser("bob", "member", "SHA256:bobkey"); err != nil {
204+
t.Fatal(err)
205+
}
206+
h := svc.WebHandler(WebConfig{Title: "files.test"})
207+
208+
rr := httptest.NewRecorder()
209+
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/", nil))
210+
if rr.Code != http.StatusOK {
211+
t.Fatalf("~bob (member, empty site): want 200, got %d", rr.Code)
212+
}
213+
if !strings.Contains(rr.Body.String(), "(empty)") {
214+
t.Fatalf("~bob should render an empty listing: %.200s", rr.Body.String())
215+
}
216+
217+
rr = httptest.NewRecorder()
218+
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~bob/nope.txt", nil))
219+
if rr.Code != http.StatusNotFound {
220+
t.Fatalf("~bob/nope.txt: want 404, got %d", rr.Code)
221+
}
222+
223+
rr = httptest.NewRecorder()
224+
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~nobody/", nil))
225+
if rr.Code != http.StatusNotFound {
226+
t.Fatalf("~nobody (unknown): want 404, got %d", rr.Code)
227+
}
228+
}
229+
198230
func TestWebAnonCannotEscape(t *testing.T) {
199231
h, _ := webTestHandler(t)
200232
cookie := loginCookie(t, h)

0 commit comments

Comments
 (0)