package files import ( "crypto/rand" "encoding/hex" "errors" "fmt" "html/template" "io" "net/http" "os" "path" "sort" "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) // index (~user dir) + /~name browsing + authed manager 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("/public", h.handleAnon) // shared public area (anon read-only) mux.HandleFunc("/public/", h.handleAnon) // shared public area (anon read-only) 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) { // Anonymous per-member public browsing: /~[/...]. No session required. if strings.HasPrefix(r.URL.Path, "/~") { h.handleAnon(w, r) return } name, ok := h.lookup(r) if !ok { // Not signed in: the root is a public directory of members' ~user sites, // with a sign-in link — not a login wall. h.renderIndex(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 { // GET /login renders the sign-in form (the root is the public index). if _, ok := h.lookup(r); ok { http.Redirect(w, r, "/", http.StatusSeeOther) return } h.renderLogin(w, "") 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}) } // renderIndex serves the public landing page: a directory of members' ~user // sites (each linking to their public /site), plus a link to the shared /public // area and a sign-in link. No authentication required. func (h *webSrv) renderIndex(w http.ResponseWriter, errMsg string) { data := indexData{Title: h.cfg.Title, Err: errMsg} if peers, err := h.svc.PublicSites(); err == nil { for _, p := range peers { data.Peers = append(data.Peers, indexPeer{Name: p.Name, URL: "/~" + p.Name + "/", UsedH: humanSize(p.Bytes)}) } } w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = indexTmpl.Execute(w, data) } // handleAnon serves the unauthenticated, read-only public surface: the shared // /public area and each member's public site at /~. Directories render a // browse listing; files stream with a content type and a short cache. It is // confined to the area root by the same safeJoin guard as SFTP — there is no // path to a member's private /me or above the area root. func (h *webSrv) handleAnon(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } upath := path.Clean("/" + strings.TrimPrefix(r.URL.Path, "/")) var name, rel, prefix, heading string switch { case upath == "/public" || strings.HasPrefix(upath, "/public/"): name, rel, prefix, heading = "", strings.TrimPrefix(upath, "/public"), "/public", "/public" case strings.HasPrefix(upath, "/~"): seg := strings.SplitN(strings.TrimPrefix(upath, "/~"), "/", 2) name = seg[0] if name == "" { http.Redirect(w, r, "/", http.StatusSeeOther) return } if len(seg) == 2 { rel = "/" + seg[1] } prefix, heading = "/~"+name, "~"+name default: http.NotFound(w, r) return } root, ok, err := h.svc.AnonRoot(name) if err != nil || !ok { http.NotFound(w, r) return } real, err := h.svc.SafeJoin(root, strings.TrimPrefix(rel, "/")) if err != nil { http.NotFound(w, r) // escaped the area root return } fi, err := os.Stat(real) if err != nil { // A known member whose site dir hasn't been created yet (it is created // lazily on their first files session) renders as an empty listing — not // a 404 — so ~name is reachable as soon as the account exists. A missing // sub-path still 404s. if os.IsNotExist(err) && path.Clean("/"+strings.TrimPrefix(rel, "/")) == "/" { h.renderAnonDir(w, prefix, heading, rel, real) return } http.NotFound(w, r) return } if fi.IsDir() { h.renderAnonDir(w, prefix, heading, rel, real) return } f, err := os.Open(real) if err != nil { http.NotFound(w, r) return } defer f.Close() w.Header().Set("Cache-Control", "public, max-age=300") http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) } // renderAnonDir renders a read-only listing of a public directory. prefix is the // area URL base ("/public" or "/~name"); rel is the path within it; real is the // on-disk directory (already confined by handleAnon). func (h *webSrv) renderAnonDir(w http.ResponseWriter, prefix, heading, rel, real string) { des, err := os.ReadDir(real) if err != nil && !os.IsNotExist(err) { http.Error(w, "cannot list files", http.StatusInternalServerError) return } // A not-yet-created site dir lists as empty (des is nil). rel = path.Clean("/" + strings.TrimPrefix(rel, "/")) data := anonData{Title: h.cfg.Title, CurPath: heading} if rel != "/" { data.CurPath = heading + rel parent := path.Dir(rel) up := prefix if parent != "/" { up = prefix + parent } data.UpURL, data.UpOK = up+"/", true } for _, de := range des { fi, err := de.Info() if err != nil { continue } url := prefix + path.Join(rel, de.Name()) if de.IsDir() { url += "/" } data.Entries = append(data.Entries, anonEntry{ Name: de.Name(), IsDir: de.IsDir(), SizeH: humanSize(fi.Size()), URL: url, Mod: fi.ModTime().Format("2006-01-02 15:04"), }) } sortEntries(data.Entries) w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = anonTmpl.Execute(w, data) } // sortEntries orders a listing directories-first, then by name. func sortEntries(es []anonEntry) { sort.Slice(es, func(i, j int) bool { if es[i].IsDir != es[j].IsDir { return es[i].IsDir } return es[i].Name < es[j].Name }) } // --- 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)

Your /site files are public at ~{{.User}}. Also reachable over SFTP with your SSH key: sftp files@{{.Title}}.

`)) type indexPeer struct { Name string URL string UsedH string } type indexData struct { Title string Peers []indexPeer Err string } type anonEntry struct { Name string IsDir bool SizeH string URL string Mod string } type anonData struct { Title string CurPath string UpURL string UpOK bool Entries []anonEntry } var indexTmpl = template.Must(template.New("index").Parse(` {{.Title}}

{{.Title}}

{{if .Err}}
{{.Err}}
{{end}}

Public member sites. Each links to that member's shared /site files. Sign in to manage your own files (private /me + your /site).

{{range .Peers}}{{end}} {{if not .Peers}}{{end}}
MemberSize
📂 ~{{.Name}}{{.UsedH}}
No public sites yet — sign in and add files to /site.

Publish over SFTP: scp file files@{{.Title}}:/site/ → appears at ~yourname.

`)) var anonTmpl = template.Must(template.New("anon").Parse(` {{.CurPath}} · {{.Title}}

{{.Title}}

{{.CurPath}}

Read-only public area.

{{if .UpOK}}{{end}} {{range .Entries}}{{end}} {{if not .Entries}}{{end}}
NameSizeModified
⬑ ..
{{if .IsDir}}📁 {{.Name}}/{{else}}📄 {{.Name}}{{end}} {{if not .IsDir}}{{.SizeH}}{{end}}{{.Mod}}
(empty)
`)) // --- 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]) }