diff --git a/cmd/agentbbs/fileweb.go b/cmd/agentbbs/fileweb.go
new file mode 100644
index 0000000..93acafb
--- /dev/null
+++ b/cmd/agentbbs/fileweb.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+ "errors"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/log"
+ "github.com/profullstack/agentbbs/internal/files"
+ "github.com/profullstack/agentbbs/internal/mailbox"
+ "github.com/profullstack/agentbbs/internal/store"
+)
+
+// startFilesWeb serves the browser-based file manager (files.) on a
+// loopback address Caddy reverse-proxies. Members sign in with their webmail
+// password — no SSH key needed — and browse the same /me and /public areas as
+// SFTP. No-op when Files is disabled (a.files == nil).
+func (a *app) startFilesWeb() {
+ if a.files == nil {
+ return
+ }
+ addr := env("AGENTBBS_FILES_WEB_ADDR", "127.0.0.1:8092")
+ title := env("AGENTBBS_FILES_WEB_TITLE", "files."+strings.TrimPrefix(a.host, "bbs."))
+ h := a.files.WebHandler(files.WebConfig{Authenticate: a.filesWebAuth, Title: title})
+ srv := &http.Server{Addr: addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
+ go func() {
+ log.Info("files web listening", "addr", addr)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Error("files web", "err", err)
+ }
+ }()
+}
+
+// filesWebAuth validates a member's webmail credentials against the Mailu IMAP
+// backend (the same login Roundcube uses), then maps them to the account. The
+// username may be a bare handle or a full address; only the local part matters.
+func (a *app) filesWebAuth(user, pass string) (store.User, bool, error) {
+ name := strings.ToLower(strings.TrimSpace(user))
+ if at := strings.IndexByte(name, '@'); at >= 0 {
+ name = name[:at]
+ }
+ if name == "" || pass == "" {
+ return store.User{}, false, nil
+ }
+ u, ok, err := a.st.UserByName(name)
+ if err != nil {
+ return store.User{}, false, err
+ }
+ if !ok || u.Banned {
+ return store.User{}, false, nil
+ }
+ imapAddr := env("AGENTBBS_MAIL_IMAP_ADDR", a.mailHost+":993")
+ plaintext := os.Getenv("AGENTBBS_MAIL_IMAP_PLAINTEXT") == "1"
+ if err := mailbox.VerifyLogin(imapAddr, name+"@"+a.mailDomain, pass, plaintext); err != nil {
+ return store.User{}, false, nil // credentials rejected
+ }
+ return u, true, nil
+}
diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go
index ef76b4e..fb896c5 100644
--- a/cmd/agentbbs/main.go
+++ b/cmd/agentbbs/main.go
@@ -258,6 +258,10 @@ func main() {
// Caddy proxies wss://host/play to it.
go a.serveGameWS(env("AGENTBBS_GAME_WS_ADDR", "127.0.0.1:8090"))
+ // Web file browser (files.): webmail-password login over the same
+ // /me + /public storage as SFTP. Loopback; Caddy proxies files. to it.
+ a.startFilesWeb()
+
// News (NNTP) server: the members-only Usenet network (docs/news.md). The
// loopback plaintext listener backs the in-BBS news@ reader; the public
// NNTPS listener (:563, TLS) serves desktop newsreaders and agents. Free for
diff --git a/internal/files/fs.go b/internal/files/fs.go
index 22d66a2..9aefe32 100644
--- a/internal/files/fs.go
+++ b/internal/files/fs.go
@@ -246,6 +246,98 @@ func (s *session) readFile(vpath string, max int64) (data []byte, truncated bool
return buf[:n], false, nil
}
+// errQuota means a write would push the member's private workspace over quota.
+var errQuota = errors.New("files: quota exceeded")
+
+// webOpen opens a file for HTTP download. It rejects the synthetic root and
+// directories; the caller closes the returned file.
+func (s *session) webOpen(vpath string) (*os.File, os.FileInfo, error) {
+ res, err := s.resolve(vpath)
+ if err != nil {
+ return nil, nil, err
+ }
+ if res.root {
+ return nil, nil, os.ErrInvalid
+ }
+ f, err := os.Open(res.real)
+ if err != nil {
+ return nil, nil, err
+ }
+ fi, err := f.Stat()
+ if err != nil {
+ _ = f.Close()
+ return nil, nil, err
+ }
+ if fi.IsDir() {
+ _ = f.Close()
+ return nil, nil, os.ErrInvalid
+ }
+ return f, fi, nil
+}
+
+// webSave stores an uploaded file at the destination vpath, enforcing the
+// private-area quota. It returns errQuota when the upload would exceed it.
+func (s *session) webSave(vpath string, r io.Reader) (int64, error) {
+ res, err := s.resolve(vpath)
+ if err != nil {
+ return 0, err
+ }
+ if res.root || !res.writable {
+ return 0, os.ErrPermission
+ }
+ var existing int64
+ if fi, e := os.Stat(res.real); e == nil {
+ if fi.IsDir() {
+ return 0, os.ErrPermission
+ }
+ existing = fi.Size()
+ }
+ f, err := os.OpenFile(res.real, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
+ if err != nil {
+ return 0, err
+ }
+ limit := int64(-1) // public area is operator-managed, unmetered
+ if res.area == areaMe {
+ if limit = s.quota - (s.used.Load() - existing); limit < 0 {
+ limit = 0
+ }
+ }
+ n, werr := copyLimited(f, r, limit)
+ cerr := f.Close()
+ if werr != nil {
+ _ = os.Remove(res.real)
+ return 0, werr
+ }
+ if cerr != nil {
+ return 0, cerr
+ }
+ if res.area == areaMe {
+ s.used.Add(n - existing)
+ }
+ return n, nil
+}
+
+// webMkdir and webRemove reuse the SFTP-side guards (root/writable checks).
+func (s *session) webMkdir(vpath string) error { return s.mkdir(vpath) }
+func (s *session) webRemove(vpath string) error { return s.remove(vpath) }
+
+// copyLimited copies r into w. When limit >= 0 it returns errQuota if the source
+// has more than limit bytes (after writing exactly limit). limit < 0 is unbounded.
+func copyLimited(w io.Writer, r io.Reader, limit int64) (int64, error) {
+ if limit < 0 {
+ return io.Copy(w, r)
+ }
+ n, err := io.Copy(w, io.LimitReader(r, limit))
+ if err != nil {
+ return n, err
+ }
+ var probe [1]byte
+ if m, _ := r.Read(probe[:]); m > 0 {
+ return n, errQuota
+ }
+ return n, nil
+}
+
// --- pkg/sftp request handlers ----------------------------------------------
// Fileread serves downloads.
diff --git a/internal/files/web.go b/internal/files/web.go
new file mode 100644
index 0000000..317982c
--- /dev/null
+++ b/internal/files/web.go
@@ -0,0 +1,473 @@
+package files
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "os"
+ "path"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/profullstack/agentbbs/internal/store"
+)
+
+// WebConfig configures the browser-facing file manager served at
+// files.. Authenticate validates a member's webmail credentials.
+type WebConfig struct {
+ // Authenticate returns the member and true when user+pass are valid. user
+ // may be a bare handle ("alice") or a full address ("alice@host").
+ Authenticate func(user, pass string) (store.User, bool, error)
+ // Title is shown in the page header (e.g. "files.profullstack.com").
+ Title string
+ // SessionTTL defaults to 12h.
+ SessionTTL time.Duration
+}
+
+// webSrv holds the live login sessions for the web file manager.
+type webSrv struct {
+ svc *Service
+ cfg WebConfig
+ mu sync.Mutex
+ sess map[string]webSession // cookie token -> session
+}
+
+type webSession struct {
+ name string
+ exp time.Time
+}
+
+const webCookie = "fsess"
+
+// WebHandler returns the HTTP handler for the web file browser. Members log in
+// with their webmail username + password and browse the same /me and /public
+// areas as SFTP — no SSH key required, and no home directory is ever exposed.
+func (s *Service) WebHandler(cfg WebConfig) http.Handler {
+ if cfg.SessionTTL <= 0 {
+ cfg.SessionTTL = 12 * time.Hour
+ }
+ if cfg.Title == "" {
+ cfg.Title = "AgentBBS Files"
+ }
+ h := &webSrv{svc: s, cfg: cfg, sess: map[string]webSession{}}
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", h.handleRoot)
+ mux.HandleFunc("/login", h.handleLogin)
+ mux.HandleFunc("/logout", h.handleLogout)
+ mux.HandleFunc("/download", h.handleDownload)
+ mux.HandleFunc("/upload", h.handleUpload)
+ mux.HandleFunc("/mkdir", h.handleMkdir)
+ mux.HandleFunc("/delete", h.handleDelete)
+ mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
+ return mux
+}
+
+// --- session helpers --------------------------------------------------------
+
+func (h *webSrv) lookup(r *http.Request) (string, bool) {
+ c, err := r.Cookie(webCookie)
+ if err != nil {
+ return "", false
+ }
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ s, ok := h.sess[c.Value]
+ if !ok {
+ return "", false
+ }
+ if time.Now().After(s.exp) {
+ delete(h.sess, c.Value)
+ return "", false
+ }
+ return s.name, true
+}
+
+func (h *webSrv) set(w http.ResponseWriter, r *http.Request, name string) {
+ tok := randHex(24)
+ h.mu.Lock()
+ h.sess[tok] = webSession{name: name, exp: time.Now().Add(h.cfg.SessionTTL)}
+ h.mu.Unlock()
+ http.SetCookie(w, &http.Cookie{
+ Name: webCookie, Value: tok, Path: "/", HttpOnly: true,
+ Secure: secureReq(r), SameSite: http.SameSiteLaxMode, MaxAge: int(h.cfg.SessionTTL.Seconds()),
+ })
+}
+
+func (h *webSrv) clear(w http.ResponseWriter, r *http.Request) {
+ if c, err := r.Cookie(webCookie); err == nil {
+ h.mu.Lock()
+ delete(h.sess, c.Value)
+ h.mu.Unlock()
+ }
+ http.SetCookie(w, &http.Cookie{Name: webCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
+}
+
+func secureReq(r *http.Request) bool {
+ return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
+}
+
+func randHex(n int) string {
+ b := make([]byte, n)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+// --- handlers ---------------------------------------------------------------
+
+func (h *webSrv) handleRoot(w http.ResponseWriter, r *http.Request) {
+ name, ok := h.lookup(r)
+ if !ok {
+ h.renderLogin(w, "")
+ return
+ }
+ vpath := cleanVPath(r.URL.Query().Get("path"))
+ sess, u, err := h.svc.OpenFor(name)
+ if err != nil {
+ h.clear(w, r)
+ h.renderLogin(w, "Your account could not be opened — sign in again.")
+ return
+ }
+ ents, err := sess.entries(vpath)
+ if err != nil {
+ // Bad path → fall back to the private workspace root.
+ vpath = "/me"
+ ents, err = sess.entries(vpath)
+ if err != nil {
+ http.Error(w, "cannot list files", http.StatusInternalServerError)
+ return
+ }
+ }
+ usage, _ := h.svc.Usage(u)
+ data := listData{
+ Title: h.cfg.Title,
+ User: name,
+ Path: vpath,
+ Crumbs: crumbs(vpath),
+ Writable: sess.canWrite(vpath) && vpath != "/",
+ UsedH: humanSize(usage.Bytes),
+ QuotaH: humanSize(usage.Quota),
+ Err: r.URL.Query().Get("err"),
+ Msg: r.URL.Query().Get("msg"),
+ }
+ if p := parentOf(vpath); p != vpath {
+ data.Parent, data.ParentOK = p, true
+ }
+ for _, e := range ents {
+ data.Entries = append(data.Entries, webEntry{
+ Name: e.Name, IsDir: e.IsDir, SizeH: humanSize(e.Size),
+ Path: path.Join(vpath, e.Name),
+ Mod: e.ModTime.Format("2006-01-02 15:04"),
+ })
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = listTmpl.Execute(w, data)
+}
+
+func (h *webSrv) handleLogin(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
+ }
+ _ = r.ParseForm()
+ user := strings.TrimSpace(r.FormValue("user"))
+ pass := r.FormValue("pass")
+ if user == "" || pass == "" {
+ h.renderLogin(w, "Enter your username and webmail password.")
+ return
+ }
+ u, ok, err := h.cfg.Authenticate(user, pass)
+ if err != nil || !ok {
+ h.renderLogin(w, "Invalid username or password.")
+ return
+ }
+ h.set(w, r, u.Name)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (h *webSrv) handleLogout(w http.ResponseWriter, r *http.Request) {
+ h.clear(w, r)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (h *webSrv) handleDownload(w http.ResponseWriter, r *http.Request) {
+ sess, ok := h.session(w, r)
+ if !ok {
+ return
+ }
+ vpath := cleanVPath(r.URL.Query().Get("path"))
+ f, fi, err := sess.webOpen(vpath)
+ if err != nil {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ defer f.Close()
+ w.Header().Set("Content-Disposition", "attachment; filename=\""+path.Base(vpath)+"\"")
+ w.Header().Set("Content-Type", "application/octet-stream")
+ w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
+ _, _ = io.Copy(w, f)
+}
+
+func (h *webSrv) handleUpload(w http.ResponseWriter, r *http.Request) {
+ sess, ok := h.session(w, r)
+ if !ok {
+ return
+ }
+ dir := cleanVPath(r.FormValue("dir"))
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
+ h.redirect(w, r, dir, "upload failed")
+ return
+ }
+ file, hdr, err := r.FormFile("file")
+ if err != nil {
+ h.redirect(w, r, dir, "no file chosen")
+ return
+ }
+ defer file.Close()
+ fname := path.Base(strings.TrimSpace(hdr.Filename))
+ if fname == "" || fname == "." || fname == "/" {
+ h.redirect(w, r, dir, "bad filename")
+ return
+ }
+ dest := path.Join(dir, fname)
+ if _, err := sess.webSave(dest, file); err != nil {
+ if errors.Is(err, errQuota) {
+ h.redirect(w, r, dir, "over quota — upload too large")
+ return
+ }
+ if errors.Is(err, os.ErrPermission) {
+ h.redirect(w, r, dir, "this area is read-only")
+ return
+ }
+ h.redirect(w, r, dir, "upload failed")
+ return
+ }
+ h.redirectMsg(w, r, dir, "uploaded "+fname)
+}
+
+func (h *webSrv) handleMkdir(w http.ResponseWriter, r *http.Request) {
+ sess, ok := h.session(w, r)
+ if !ok {
+ return
+ }
+ dir := cleanVPath(r.FormValue("dir"))
+ name := path.Base(strings.TrimSpace(r.FormValue("name")))
+ if name == "" || name == "." || name == "/" {
+ h.redirect(w, r, dir, "bad folder name")
+ return
+ }
+ if err := sess.webMkdir(path.Join(dir, name)); err != nil {
+ h.redirect(w, r, dir, "could not create folder")
+ return
+ }
+ h.redirectMsg(w, r, dir, "created "+name)
+}
+
+func (h *webSrv) handleDelete(w http.ResponseWriter, r *http.Request) {
+ sess, ok := h.session(w, r)
+ if !ok {
+ return
+ }
+ target := cleanVPath(r.FormValue("path"))
+ parent := parentOf(target)
+ if err := sess.webRemove(target); err != nil {
+ h.redirect(w, r, parent, "could not delete")
+ return
+ }
+ h.redirectMsg(w, r, parent, "deleted "+path.Base(target))
+}
+
+// session resolves the logged-in member into a filesystem session, writing an
+// auth error to w when there is none.
+func (h *webSrv) session(w http.ResponseWriter, r *http.Request) (*session, bool) {
+ name, ok := h.lookup(r)
+ if !ok {
+ http.Error(w, "not signed in", http.StatusUnauthorized)
+ return nil, false
+ }
+ sess, _, err := h.svc.OpenFor(name)
+ if err != nil {
+ http.Error(w, "account error", http.StatusInternalServerError)
+ return nil, false
+ }
+ return sess, true
+}
+
+func (h *webSrv) redirect(w http.ResponseWriter, r *http.Request, dir, errMsg string) {
+ u := "/?path=" + urlEsc(dir)
+ if errMsg != "" {
+ u += "&err=" + urlEsc(errMsg)
+ }
+ http.Redirect(w, r, u, http.StatusSeeOther)
+}
+
+func (h *webSrv) redirectMsg(w http.ResponseWriter, r *http.Request, dir, msg string) {
+ http.Redirect(w, r, "/?path="+urlEsc(dir)+"&msg="+urlEsc(msg), http.StatusSeeOther)
+}
+
+func (h *webSrv) renderLogin(w http.ResponseWriter, errMsg string) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = loginTmpl.Execute(w, loginData{Title: h.cfg.Title, Err: errMsg})
+}
+
+// --- view models + templates ------------------------------------------------
+
+type loginData struct {
+ Title string
+ Err string
+}
+
+type crumb struct {
+ Name string
+ Path string
+}
+
+type webEntry struct {
+ Name string
+ IsDir bool
+ SizeH string
+ Path string
+ Mod string
+}
+
+type listData struct {
+ Title string
+ User string
+ Path string
+ Crumbs []crumb
+ Parent string
+ ParentOK bool
+ Entries []webEntry
+ Writable bool
+ UsedH string
+ QuotaH string
+ Err string
+ Msg string
+}
+
+var baseCSS = `
+*{box-sizing:border-box}body{margin:0;background:#0f172a;color:#e2e8f0;font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}
+a{color:#4ade80;text-decoration:none}a:hover{text-decoration:underline}
+.wrap{max-width:860px;margin:0 auto;padding:24px}
+.bar{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #1e293b;padding-bottom:12px;margin-bottom:16px}
+.bar h1{font-size:18px;margin:0;color:#4ade80}
+.muted{color:#94a3b8;font-size:13px}
+table{width:100%;border-collapse:collapse}
+td,th{text-align:left;padding:8px 6px;border-bottom:1px solid #1e293b}
+th{color:#94a3b8;font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
+.right{text-align:right}
+.btn{background:#16a34a;color:#fff;border:0;padding:8px 14px;border-radius:6px;cursor:pointer;font-size:14px}
+.btn.sm{padding:4px 9px;font-size:13px}
+.btn.danger{background:#b91c1c}
+input[type=text],input[type=password],input[type=file]{background:#1e293b;border:1px solid #334155;color:#e2e8f0;padding:8px;border-radius:6px;font-size:14px}
+form.inline{display:inline}
+.tools{display:flex;gap:18px;flex-wrap:wrap;align-items:center;margin:18px 0;padding:14px;background:#111c33;border:1px solid #1e293b;border-radius:8px}
+.tools form{display:flex;gap:8px;align-items:center}
+.flash{padding:10px 12px;border-radius:6px;margin-bottom:14px}
+.flash.err{background:#3f1d1d;color:#fecaca}
+.flash.ok{background:#14321f;color:#bbf7d0}
+.card{max-width:380px;margin:8vh auto;padding:28px;background:#111c33;border:1px solid #1e293b;border-radius:10px}
+.card h1{color:#4ade80;margin:0 0 4px}.card label{display:block;margin:14px 0 4px;font-size:13px;color:#94a3b8}
+.card input{width:100%}
+.crumbs a{color:#94a3b8}.crumbs b{color:#e2e8f0}
+`
+
+var loginTmpl = template.Must(template.New("login").Parse(`
+{{.Title}}
+
+
{{.Title}}
+
Sign in with your AgentBBS username and your webmail password.
+{{if .Err}}
{{.Err}}
{{end}}
+
+
Forgot it? Re-run ssh join@ to reset your webmail password. Not a member? ssh join@bbs.profullstack.com
+
`))
+
+var listTmpl = template.Must(template.New("list").Parse(`
+{{.Title}}
+
+
{{.Title}}
+
{{.User}} · {{.UsedH}} / {{.QuotaH}} used ·
+
+{{if .Err}}
{{.Err}}
{{end}}
+{{if .Msg}}
{{.Msg}}
{{end}}
+
+{{if .Writable}}
+
+
+
{{else}}
This area is read-only.
{{end}}
+
| Name | Size | Modified | |
+{{if .ParentOK}}| ⬑ .. | | | |
{{end}}
+{{range .Entries}}
+| {{if .IsDir}}📁 {{.Name}}/{{else}}📄 {{.Name}}{{end}} |
+{{if not .IsDir}}{{.SizeH}}{{end}} | {{.Mod}} |
+{{if $.Writable}}{{end}} |
+
{{end}}
+{{if not .Entries}}| (empty) |
{{end}}
+
+
Also reachable over SFTP: sftp files@files.profullstack.com (with your SSH key).
+
`))
+
+// --- path helpers -----------------------------------------------------------
+
+// cleanVPath normalizes a virtual path to an absolute, lexically clean form.
+func cleanVPath(p string) string {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ return "/me"
+ }
+ return path.Clean("/" + strings.TrimPrefix(p, "/"))
+}
+
+func parentOf(p string) string {
+ p = cleanVPath(p)
+ if p == "/" {
+ return "/"
+ }
+ return cleanVPath(path.Dir(p))
+}
+
+func crumbs(p string) []crumb {
+ p = cleanVPath(p)
+ if p == "/" {
+ return nil
+ }
+ var out []crumb
+ acc := ""
+ for _, seg := range strings.Split(strings.TrimPrefix(p, "/"), "/") {
+ acc += "/" + seg
+ // Raw clean path: html/template URL-encodes it in the href query context.
+ out = append(out, crumb{Name: seg, Path: acc})
+ }
+ return out
+}
+
+func urlEsc(p string) string {
+ // path is already clean; escape just the characters that would break a query.
+ r := strings.NewReplacer("%", "%25", "&", "%26", "?", "%3F", "#", "%23", " ", "%20", "+", "%2B")
+ return r.Replace(p)
+}
+
+func humanSize(n int64) string {
+ const unit = 1024
+ if n < unit {
+ return fmt.Sprintf("%d B", n)
+ }
+ div, exp := int64(unit), 0
+ for x := n / unit; x >= unit; x /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
+}
diff --git a/internal/files/web_test.go b/internal/files/web_test.go
new file mode 100644
index 0000000..8203b55
--- /dev/null
+++ b/internal/files/web_test.go
@@ -0,0 +1,154 @@
+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")
+ }
+}
+
+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)
+ }
+}
diff --git a/internal/mailbox/imap.go b/internal/mailbox/imap.go
index 36711f0..2786c1e 100644
--- a/internal/mailbox/imap.go
+++ b/internal/mailbox/imap.go
@@ -58,6 +58,27 @@ func NewIMAPTransport(cfg IMAPConfig) (Transport, error) {
return &imapTransport{cfg: cfg, c: c}, nil
}
+// VerifyLogin checks a username/password against the IMAP backend by logging in
+// and immediately logging out. It returns nil only when the credentials are
+// accepted. The web file browser uses this to authenticate members with their
+// webmail (Mailu/Dovecot) password — the same credential Roundcube uses.
+func VerifyLogin(addr, user, pass string, plaintext bool) error {
+ dial := imapclient.DialTLS
+ if plaintext {
+ dial = imapclient.DialInsecure
+ }
+ c, err := dial(addr, nil)
+ if err != nil {
+ return fmt.Errorf("imap dial %s: %w", addr, err)
+ }
+ defer func() { _ = c.Close() }()
+ if err := c.Login(user, pass).Wait(); err != nil {
+ return fmt.Errorf("imap login: %w", err)
+ }
+ _ = c.Logout().Wait()
+ return nil
+}
+
func (t *imapTransport) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
diff --git a/setup.sh b/setup.sh
index 485fb81..7f02cf3 100755
--- a/setup.sh
+++ b/setup.sh
@@ -31,6 +31,8 @@ SRC_DIR="${SRC_DIR:-/opt/agentbbs}"
DATA_DIR="${DATA_DIR:-/var/lib/agentbbs}"
ASK_ADDR="${ASK_ADDR:-127.0.0.1:8081}" # agentbbs on-demand-TLS ask endpoint (must match agentbbs.env)
HTTP_ADDR="${HTTP_ADDR:-127.0.0.1:8088}" # agentbbs /verify endpoint (join@ email confirmation links)
+FILES_WEB_ADDR="${FILES_WEB_ADDR:-127.0.0.1:8092}" # agentbbs web file browser (Caddy fronts files.${DOMAIN#*.})
+FILES_DOMAIN="${FILES_DOMAIN:-files.${DOMAIN#*.}}" # web file browser host (default: files.)
GO_VERSION="${GO_VERSION:-1.26.4}"
POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}"
FETCH_ASSETS="${FETCH_ASSETS:-1}" # set 0 to skip the DOOM/Freedoom arcade assets
@@ -263,6 +265,20 @@ Invaders, Pac-Man, Tetris, Snake & Hangman.
(or the Mail entry in the hub). Premium members get a forwarding
name@${DOMAIN} address.
+IRC from a desktop client — irssi, HexChat, WeeChat
+The members' IRC lives at ${IRC_DOMAIN}:6697 (TLS). Members authenticate with
+SASL PLAIN: username = your BBS name,
+password = your IRC password. The web client
+(chat.${DOMAIN}) is already configured; for a desktop client, set it up once. irssi:
+/network add -sasl_username YOURNAME -sasl_password YOURPASSWORD -sasl_mechanism PLAIN ProfullstackBBS
+/server add -tls -tls_verify -network ProfullstackBBS ${IRC_DOMAIN} 6697
+/connect ProfullstackBBS
+# then /join #general
+Connect by the network name ProfullstackBBS — not the hostname — or
+SASL isn't sent and the server replies ACCOUNT_REQUIRED. HexChat /
+WeeChat: server ${IRC_DOMAIN}/6697, TLS on, SASL PLAIN with the
+same username + password.
+
Git, the easy way
Membership is your git account. The SSH key you sign in with is your push
key — no passwords:
@@ -356,6 +372,9 @@ AGENTBBS_ASK_ADDR=${ASK_ADDR}
# https://${DOMAIN}/verify, which Caddy proxies to this loopback endpoint.
# Without SMTP config the link is only logged (journalctl -u agentbbs).
AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
+# Web file browser (https://${FILES_DOMAIN}): loopback server Caddy fronts.
+# Members sign in with their webmail password; same /me + /public as SFTP.
+AGENTBBS_FILES_WEB_ADDR=${FILES_WEB_ADDR}
# AGENTBBS_SMTP_HOST=
# AGENTBBS_SMTP_PORT=587
# AGENTBBS_SMTP_USER=
@@ -670,6 +689,17 @@ ${MAIL_DOMAIN} {
"
fi
+# Web file browser (${FILES_DOMAIN}): Caddy terminates TLS and proxies to the
+# agentbbs loopback file-manager server. Members sign in with their webmail
+# password and browse the same /me + /public areas as SFTP. Needs A record
+# ${FILES_DOMAIN} -> this host. Disabled when AGENTBBS_FILES=0 (server not up).
+FILES_SITE="
+${FILES_DOMAIN} {
+ encode zstd gzip
+ reverse_proxy http://${FILES_WEB_ADDR}
+}
+"
+
cat > /etc/caddy/Caddyfile <.${DOMAIN} (needs wildcard DNS
# *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's
# ask endpoint confirms is a registered member, so random subdomains