feat(files): web file browser at files.<host> with webmail-password login

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.<host>. 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.<DOMAIN> 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 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-23 14:09:18 +00:00
parent 1dbb2c70e2
commit cddd9819cc
7 changed files with 835 additions and 1 deletions

60
cmd/agentbbs/fileweb.go Normal file
View file

@ -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.<host>) 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
}

View file

@ -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.<host>): webmail-password login over the same
// /me + /public storage as SFTP. Loopback; Caddy proxies files.<host> 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