From cddd9819cc8e5b7ba0f93481361a9809e85df3c4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 14:09:18 +0000 Subject: [PATCH] feat(files): web file browser at files. with webmail-password login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a browser-based file manager so members can use their files without an SSH key. Served on a loopback HTTP server (AGENTBBS_FILES_WEB_ADDR, default 127.0.0.1:8092) that Caddy fronts at files.. Members sign in with their webmail username + password, verified against the Mailu IMAP backend (mailbox.VerifyLogin), and browse the same virtual /me + /public areas as SFTP — no home directory is ever exposed. Upload/download/mkdir/delete with the private-area quota enforced; reuses internal/files confinement (fs.go). setup.sh renders the files. Caddy site + env knob. Unit tests cover the auth gate and an upload/list/download/delete round trip. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/fileweb.go | 60 +++++ cmd/agentbbs/main.go | 4 + internal/files/fs.go | 92 ++++++++ internal/files/web.go | 473 +++++++++++++++++++++++++++++++++++++ internal/files/web_test.go | 154 ++++++++++++ internal/mailbox/imap.go | 21 ++ setup.sh | 32 ++- 7 files changed, 835 insertions(+), 1 deletion(-) create mode 100644 cmd/agentbbs/fileweb.go create mode 100644 internal/files/web.go create mode 100644 internal/files/web_test.go 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}} +
/{{range .Crumbs}} {{.Name}} /{{end}}
+{{if .Writable}}
+
+
+
+
+
{{else}}

This area is read-only.

{{end}} + +{{if .ParentOK}}{{end}} +{{range .Entries}} + + + +{{end}} +{{if not .Entries}}{{end}} +
NameSizeModified
⬑ ..
{{if .IsDir}}📁 {{.Name}}/{{else}}📄 {{.Name}}{{end}}{{if not .IsDir}}{{.SizeH}}{{end}}{{.Mod}}{{if $.Writable}}
+
{{end}}
(empty)
+

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