mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Add a third storage area, /site — each member's own public root, served
unauthenticated on the web at ~<name> alongside the shared /public.
Web file host (files.<host>) is no longer a login wall:
- GET / -> directory of members' ~user sites (+ sign-in link)
- GET /~<name>/... -> anon read-only browse + clean file URLs of /site
- GET /public/... -> anon read-only browse + clean file URLs of shared
area (fixes bare /public requiring login: the old
Caddy `handle_path /public/*` never matched /public)
Login is now optional and gates only private /me + writes. The anon
surface has no route into anyone's /me and safeJoin rejects traversal.
Usage gauge now sums the member-owned areas (/me + /site) instead of
/me alone; shared /public stays operator-managed and unmetered.
Caddy: route all of files.<host> to the Go manager. Docs + tests updated
(anon download/browse, traversal confinement, /site metering).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
256 lines
8.6 KiB
Go
256 lines
8.6 KiB
Go
package files
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/profullstack/agentbbs/internal/store"
|
|
)
|
|
|
|
// webTestHandler builds the web handler with a stub authenticator that accepts
|
|
// alice/secret, plus a helper http client that carries the session cookie.
|
|
func webTestHandler(t *testing.T) (http.Handler, store.User) {
|
|
t.Helper()
|
|
svc, _, u := newTestService(t)
|
|
h := svc.WebHandler(WebConfig{
|
|
Title: "files.test",
|
|
Authenticate: func(user, pass string) (store.User, bool, error) {
|
|
if strings.HasPrefix(user, "alice") && pass == "secret" {
|
|
return u, true, nil
|
|
}
|
|
return store.User{}, false, nil
|
|
},
|
|
})
|
|
return h, u
|
|
}
|
|
|
|
func TestWebRequiresAuth(t *testing.T) {
|
|
h, _ := webTestHandler(t)
|
|
|
|
// Unauthenticated root shows the login form, not a file listing.
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if body := rr.Body.String(); !strings.Contains(body, "Sign in") {
|
|
t.Fatalf("expected login page, got: %.120s", body)
|
|
}
|
|
|
|
// API endpoints reject without a session.
|
|
rr = httptest.NewRecorder()
|
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/download?path=/me/x", nil))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("download without auth: want 401, got %d", rr.Code)
|
|
}
|
|
|
|
// Bad credentials do not set a session cookie.
|
|
rr = httptest.NewRecorder()
|
|
form := url.Values{"user": {"alice"}, "pass": {"wrong"}}
|
|
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
h.ServeHTTP(rr, req)
|
|
if len(rr.Result().Cookies()) != 0 {
|
|
t.Fatal("bad login should not set a cookie")
|
|
}
|
|
}
|
|
|
|
func TestWebRoundTrip(t *testing.T) {
|
|
h, _ := webTestHandler(t)
|
|
|
|
// Log in and capture the session cookie.
|
|
form := url.Values{"user": {"alice@files.test"}, "pass": {"secret"}}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
h.ServeHTTP(rr, req)
|
|
cookies := rr.Result().Cookies()
|
|
if len(cookies) == 0 {
|
|
t.Fatal("login did not set a cookie")
|
|
}
|
|
cookie := cookies[0]
|
|
auth := func(r *http.Request) { r.AddCookie(cookie) }
|
|
|
|
// Upload a file into /me.
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
_ = mw.WriteField("dir", "/me")
|
|
fw, _ := mw.CreateFormFile("file", "hello.txt")
|
|
_, _ = fw.Write([]byte("hello world"))
|
|
_ = mw.Close()
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/upload", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
auth(req)
|
|
h.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusSeeOther {
|
|
t.Fatalf("upload: want redirect, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// The listing now shows the file.
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/?path=/me", nil)
|
|
auth(req)
|
|
h.ServeHTTP(rr, req)
|
|
if !strings.Contains(rr.Body.String(), "hello.txt") {
|
|
t.Fatalf("listing missing uploaded file: %.300s", rr.Body.String())
|
|
}
|
|
|
|
// Download returns the bytes.
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/download?path=/me/hello.txt", nil)
|
|
auth(req)
|
|
h.ServeHTTP(rr, req)
|
|
if got, _ := io.ReadAll(rr.Body); string(got) != "hello world" {
|
|
t.Fatalf("download mismatch: %q", got)
|
|
}
|
|
|
|
// Delete removes it.
|
|
rr = httptest.NewRecorder()
|
|
form = url.Values{"path": {"/me/hello.txt"}}
|
|
req = httptest.NewRequest(http.MethodPost, "/delete", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
auth(req)
|
|
h.ServeHTTP(rr, req)
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/?path=/me", nil)
|
|
auth(req)
|
|
h.ServeHTTP(rr, req)
|
|
if strings.Contains(rr.Body.String(), "hello.txt") {
|
|
t.Fatal("file still present after delete")
|
|
}
|
|
}
|
|
|
|
// loginCookie logs alice in and returns her session cookie.
|
|
func loginCookie(t *testing.T, h http.Handler) *http.Cookie {
|
|
t.Helper()
|
|
form := url.Values{"user": {"alice"}, "pass": {"secret"}}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
h.ServeHTTP(rr, req)
|
|
cs := rr.Result().Cookies()
|
|
if len(cs) == 0 {
|
|
t.Fatal("login did not set a cookie")
|
|
}
|
|
return cs[0]
|
|
}
|
|
|
|
// uploadTo uploads body to dir as the holder of cookie.
|
|
func uploadTo(t *testing.T, h http.Handler, cookie *http.Cookie, dir, name, body string) {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
_ = mw.WriteField("dir", dir)
|
|
fw, _ := mw.CreateFormFile("file", name)
|
|
_, _ = fw.Write([]byte(body))
|
|
_ = mw.Close()
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/upload", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.AddCookie(cookie)
|
|
h.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusSeeOther {
|
|
t.Fatalf("upload to %s: want redirect, got %d (%s)", dir, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestWebAnonPublicSite(t *testing.T) {
|
|
h, _ := webTestHandler(t)
|
|
cookie := loginCookie(t, h)
|
|
|
|
// alice publishes to her own public /site and to the shared /public.
|
|
uploadTo(t, h, cookie, "/site", "hello.txt", "from alice site")
|
|
uploadTo(t, h, cookie, "/public", "shared.txt", "shared file")
|
|
|
|
// The unauthenticated root lists ~alice (a public-site directory), not a wall.
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if body := rr.Body.String(); !strings.Contains(body, "~alice") {
|
|
t.Fatalf("index missing ~alice directory entry: %.300s", body)
|
|
}
|
|
|
|
// Anonymous (no cookie) can download a member's published site file.
|
|
rr = httptest.NewRecorder()
|
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/hello.txt", nil))
|
|
if got, _ := io.ReadAll(rr.Body); string(got) != "from alice site" {
|
|
t.Fatalf("~alice file: got %q", got)
|
|
}
|
|
|
|
// Anonymous can download from the shared public area via a clean URL.
|
|
rr = httptest.NewRecorder()
|
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/public/shared.txt", nil))
|
|
if got, _ := io.ReadAll(rr.Body); string(got) != "shared file" {
|
|
t.Fatalf("/public file: got %q", got)
|
|
}
|
|
|
|
// Anonymous directory browse renders a listing.
|
|
rr = httptest.NewRecorder()
|
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/~alice/", nil))
|
|
if body := rr.Body.String(); !strings.Contains(body, "hello.txt") {
|
|
t.Fatalf("~alice browse missing file: %.300s", body)
|
|
}
|
|
}
|
|
|
|
func TestWebAnonCannotEscape(t *testing.T) {
|
|
h, _ := webTestHandler(t)
|
|
cookie := loginCookie(t, h)
|
|
uploadTo(t, h, cookie, "/me", "secret.txt", "private")
|
|
|
|
// There is no anonymous route into /me, and traversal out of a public area
|
|
// must not reach the private workspace.
|
|
for _, p := range []string{
|
|
"/~alice/../../users/alice/secret.txt",
|
|
"/public/../users/alice/secret.txt",
|
|
"/~alice/..%2f..%2fusers%2falice%2fsecret.txt",
|
|
"/~ghost/anything", // unknown member
|
|
} {
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil))
|
|
if body, _ := io.ReadAll(rr.Body); strings.Contains(string(body), "private") {
|
|
t.Fatalf("anon path %q leaked private content", p)
|
|
}
|
|
if rr.Code == http.StatusOK && strings.Contains(rr.Body.String(), "secret.txt") {
|
|
t.Fatalf("anon path %q exposed /me", p)
|
|
}
|
|
}
|
|
|
|
// And the authed download API still rejects /me without a session.
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/download?path=/me/secret.txt", nil))
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anon /me download: want 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestWebPublicReadOnlyByDefault(t *testing.T) {
|
|
h, _ := webTestHandler(t)
|
|
// Log in.
|
|
form := url.Values{"user": {"alice"}, "pass": {"secret"}}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
h.ServeHTTP(rr, req)
|
|
cookie := rr.Result().Cookies()[0]
|
|
|
|
// Default public_write is "members" (writable), so /public should accept an
|
|
// upload; this asserts the area resolves and writes land — the moderation
|
|
// toggle is covered in the SFTP tests.
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
_ = mw.WriteField("dir", "/public")
|
|
fw, _ := mw.CreateFormFile("file", "note.txt")
|
|
_, _ = fw.Write([]byte("shared"))
|
|
_ = mw.Close()
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/upload", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
req.AddCookie(cookie)
|
|
h.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusSeeOther {
|
|
t.Fatalf("public upload: want redirect, got %d", rr.Code)
|
|
}
|
|
}
|