mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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:
parent
1dbb2c70e2
commit
cddd9819cc
7 changed files with 835 additions and 1 deletions
60
cmd/agentbbs/fileweb.go
Normal file
60
cmd/agentbbs/fileweb.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -258,6 +258,10 @@ func main() {
|
||||||
// Caddy proxies wss://host/play to it.
|
// Caddy proxies wss://host/play to it.
|
||||||
go a.serveGameWS(env("AGENTBBS_GAME_WS_ADDR", "127.0.0.1:8090"))
|
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
|
// News (NNTP) server: the members-only Usenet network (docs/news.md). The
|
||||||
// loopback plaintext listener backs the in-BBS news@ reader; the public
|
// loopback plaintext listener backs the in-BBS news@ reader; the public
|
||||||
// NNTPS listener (:563, TLS) serves desktop newsreaders and agents. Free for
|
// NNTPS listener (:563, TLS) serves desktop newsreaders and agents. Free for
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,98 @@ func (s *session) readFile(vpath string, max int64) (data []byte, truncated bool
|
||||||
return buf[:n], false, nil
|
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 ----------------------------------------------
|
// --- pkg/sftp request handlers ----------------------------------------------
|
||||||
|
|
||||||
// Fileread serves downloads.
|
// Fileread serves downloads.
|
||||||
|
|
|
||||||
473
internal/files/web.go
Normal file
473
internal/files/web.go
Normal file
|
|
@ -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.<host>. 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(`<!doctype html><html><head><meta charset=utf-8>
|
||||||
|
<meta name=viewport content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>` + baseCSS + `</style></head>
|
||||||
|
<body><div class=card>
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
<p class=muted>Sign in with your AgentBBS username and your <b>webmail</b> password.</p>
|
||||||
|
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
|
||||||
|
<form method=post action=/login>
|
||||||
|
<label>Username</label><input type=text name=user autofocus autocomplete=username placeholder="e.g. chovy">
|
||||||
|
<label>Webmail password</label><input type=password name=pass autocomplete=current-password>
|
||||||
|
<div style="margin-top:18px"><button class=btn type=submit>Sign in</button></div>
|
||||||
|
</form>
|
||||||
|
<p class=muted style="margin-top:18px">Forgot it? Re-run <code>ssh join@</code> to reset your webmail password. Not a member? <code>ssh join@bbs.profullstack.com</code></p>
|
||||||
|
</div></body></html>`))
|
||||||
|
|
||||||
|
var listTmpl = template.Must(template.New("list").Parse(`<!doctype html><html><head><meta charset=utf-8>
|
||||||
|
<meta name=viewport content="width=device-width,initial-scale=1"><title>{{.Title}}</title><style>` + baseCSS + `</style></head>
|
||||||
|
<body><div class=wrap>
|
||||||
|
<div class=bar><h1>{{.Title}}</h1>
|
||||||
|
<div class=muted>{{.User}} · {{.UsedH}} / {{.QuotaH}} used ·
|
||||||
|
<form class=inline method=post action=/logout><button class="btn sm" type=submit>Sign out</button></form></div></div>
|
||||||
|
{{if .Err}}<div class="flash err">{{.Err}}</div>{{end}}
|
||||||
|
{{if .Msg}}<div class="flash ok">{{.Msg}}</div>{{end}}
|
||||||
|
<div class=crumbs><a href="/?path=%2F">/</a>{{range .Crumbs}} <a href="/?path={{.Path}}">{{.Name}}</a> /{{end}}</div>
|
||||||
|
{{if .Writable}}<div class=tools>
|
||||||
|
<form method=post action=/upload enctype=multipart/form-data>
|
||||||
|
<input type=hidden name=dir value="{{.Path}}"><input type=file name=file required><button class=btn type=submit>Upload</button></form>
|
||||||
|
<form method=post action=/mkdir>
|
||||||
|
<input type=hidden name=dir value="{{.Path}}"><input type=text name=name placeholder="new folder" required><button class=btn type=submit>Create folder</button></form>
|
||||||
|
</div>{{else}}<p class=muted>This area is read-only.</p>{{end}}
|
||||||
|
<table><tr><th>Name</th><th class=right>Size</th><th>Modified</th><th></th></tr>
|
||||||
|
{{if .ParentOK}}<tr><td><a href="/?path={{.Parent}}">⬑ ..</a></td><td></td><td></td><td></td></tr>{{end}}
|
||||||
|
{{range .Entries}}<tr>
|
||||||
|
<td>{{if .IsDir}}📁 <a href="/?path={{.Path}}">{{.Name}}/</a>{{else}}📄 <a href="/download?path={{.Path}}">{{.Name}}</a>{{end}}</td>
|
||||||
|
<td class=right>{{if not .IsDir}}{{.SizeH}}{{end}}</td><td class=muted>{{.Mod}}</td>
|
||||||
|
<td class=right>{{if $.Writable}}<form class=inline method=post action=/delete onsubmit="return confirm('Delete {{.Name}}?')">
|
||||||
|
<input type=hidden name=path value="{{.Path}}"><button class="btn sm danger" type=submit>Delete</button></form>{{end}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
{{if not .Entries}}<tr><td colspan=4 class=muted>(empty)</td></tr>{{end}}
|
||||||
|
</table>
|
||||||
|
<p class=muted style="margin-top:20px">Also reachable over SFTP: <code>sftp files@files.profullstack.com</code> (with your SSH key).</p>
|
||||||
|
</div></body></html>`))
|
||||||
|
|
||||||
|
// --- 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])
|
||||||
|
}
|
||||||
154
internal/files/web_test.go
Normal file
154
internal/files/web_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -58,6 +58,27 @@ func NewIMAPTransport(cfg IMAPConfig) (Transport, error) {
|
||||||
return &imapTransport{cfg: cfg, c: c}, nil
|
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 {
|
func (t *imapTransport) Close() error {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
|
|
|
||||||
32
setup.sh
32
setup.sh
|
|
@ -31,6 +31,8 @@ SRC_DIR="${SRC_DIR:-/opt/agentbbs}"
|
||||||
DATA_DIR="${DATA_DIR:-/var/lib/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)
|
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)
|
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.<root-of-DOMAIN>)
|
||||||
GO_VERSION="${GO_VERSION:-1.26.4}"
|
GO_VERSION="${GO_VERSION:-1.26.4}"
|
||||||
POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}"
|
POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}"
|
||||||
FETCH_ASSETS="${FETCH_ASSETS:-1}" # set 0 to skip the DOOM/Freedoom arcade assets
|
FETCH_ASSETS="${FETCH_ASSETS:-1}" # set 0 to skip the DOOM/Freedoom arcade assets
|
||||||
|
|
@ -263,6 +265,20 @@ Invaders, Pac-Man, Tetris, Snake & Hangman</b>.</p>
|
||||||
(or the <b class="dim">Mail</b> entry in the hub). Premium members get a forwarding
|
(or the <b class="dim">Mail</b> entry in the hub). Premium members get a forwarding
|
||||||
<code>name@${DOMAIN}</code> address.</p>
|
<code>name@${DOMAIN}</code> address.</p>
|
||||||
|
|
||||||
|
<h2>IRC from a desktop client — irssi, HexChat, WeeChat</h2>
|
||||||
|
<p class="dim">The members' IRC lives at <code>${IRC_DOMAIN}:6697</code> (TLS). Members authenticate with
|
||||||
|
<b class="dim">SASL PLAIN</b>: <b class="dim">username = your BBS name</b>,
|
||||||
|
<b class="dim">password = your IRC password</b>. The <a href="https://chat.${DOMAIN}">web client</a>
|
||||||
|
(chat.${DOMAIN}) is already configured; for a desktop client, set it up once. <b class="dim">irssi:</b></p>
|
||||||
|
<pre class="cmds"><b>/network add -sasl_username YOURNAME -sasl_password YOURPASSWORD -sasl_mechanism PLAIN ProfullstackBBS</b>
|
||||||
|
<b>/server add -tls -tls_verify -network ProfullstackBBS ${IRC_DOMAIN} 6697</b>
|
||||||
|
<b>/connect ProfullstackBBS</b>
|
||||||
|
<span># then</span> <b>/join #general</b></pre>
|
||||||
|
<p class="dim">Connect by the <i>network name</i> <code>ProfullstackBBS</code> — not the hostname — or
|
||||||
|
SASL isn't sent and the server replies <code>ACCOUNT_REQUIRED</code>. <b class="dim">HexChat /
|
||||||
|
WeeChat:</b> server <code>${IRC_DOMAIN}/6697</code>, TLS on, SASL <b class="dim">PLAIN</b> with the
|
||||||
|
same username + password.</p>
|
||||||
|
|
||||||
<h2>Git, the easy way</h2>
|
<h2>Git, the easy way</h2>
|
||||||
<p class="dim">Membership <i>is</i> your git account. The SSH key you sign in with is your push
|
<p class="dim">Membership <i>is</i> your git account. The SSH key you sign in with is your push
|
||||||
key — no passwords:</p>
|
key — no passwords:</p>
|
||||||
|
|
@ -356,6 +372,9 @@ AGENTBBS_ASK_ADDR=${ASK_ADDR}
|
||||||
# https://${DOMAIN}/verify, which Caddy proxies to this loopback endpoint.
|
# https://${DOMAIN}/verify, which Caddy proxies to this loopback endpoint.
|
||||||
# Without SMTP config the link is only logged (journalctl -u agentbbs).
|
# Without SMTP config the link is only logged (journalctl -u agentbbs).
|
||||||
AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
|
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_HOST=
|
||||||
# AGENTBBS_SMTP_PORT=587
|
# AGENTBBS_SMTP_PORT=587
|
||||||
# AGENTBBS_SMTP_USER=
|
# AGENTBBS_SMTP_USER=
|
||||||
|
|
@ -670,6 +689,17 @@ ${MAIL_DOMAIN} {
|
||||||
"
|
"
|
||||||
fi
|
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 <<CADDY
|
cat > /etc/caddy/Caddyfile <<CADDY
|
||||||
{
|
{
|
||||||
email ${ACME_EMAIL}
|
email ${ACME_EMAIL}
|
||||||
|
|
@ -713,7 +743,7 @@ ${DOMAIN} {
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
${GIT_SITE}${NEWS_SITE}${IRC_SITE}${MAIL_SITE}
|
${GIT_SITE}${NEWS_SITE}${IRC_SITE}${MAIL_SITE}${FILES_SITE}
|
||||||
# Free per-user homepages at <name>.${DOMAIN} (needs wildcard DNS
|
# Free per-user homepages at <name>.${DOMAIN} (needs wildcard DNS
|
||||||
# *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's
|
# *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's
|
||||||
# ask endpoint confirms <name> is a registered member, so random subdomains
|
# ask endpoint confirms <name> is a registered member, so random subdomains
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue