mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
Merge branch 'feat/files-sftp' into feat/notify-creds
# Conflicts: # cmd/agentbbs/main.go
This commit is contained in:
commit
95d1afb59a
45 changed files with 2924 additions and 389 deletions
|
|
@ -17,6 +17,7 @@ import (
|
|||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
// Live is one connected SSH session, as seen by the registry.
|
||||
|
|
@ -70,13 +71,13 @@ var menuItems = []struct {
|
|||
}
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
headStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa"))
|
||||
frameStyle = lipgloss.NewStyle().Padding(1, 2)
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
|
||||
dimStyle = ui.Dim
|
||||
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Green)
|
||||
warnStyle = ui.Danger
|
||||
okStyle = lipgloss.NewStyle().Foreground(ui.Green)
|
||||
headStyle = lipgloss.NewStyle().Bold(true).Foreground(ui.Blue)
|
||||
frameStyle = ui.Frame
|
||||
)
|
||||
|
||||
// Model is the admin console.
|
||||
|
|
@ -306,7 +307,7 @@ func (m Model) View() string {
|
|||
}
|
||||
|
||||
header := titleStyle.Render("AgentBBS admin") + dimStyle.Render(" · "+m.admin.Name)
|
||||
out := header + "\n\n" + body + "\n" + dimStyle.Render(help)
|
||||
out := header + "\n\n" + body + "\n" + ui.KeyBar(help)
|
||||
if m.note != "" {
|
||||
out += "\n" + m.note
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,8 +43,9 @@ var DomainNames = map[string]bool{"domain": true, "domains": true}
|
|||
|
||||
// AdminNames are usernames that route to the privileged admin console (PRD §6).
|
||||
// The route only opens for accounts whose name is in the operator allowlist
|
||||
// (see IsAdmin); the name itself confers nothing.
|
||||
var AdminNames = map[string]bool{"admin": true, "sysop": true}
|
||||
// (see IsAdmin); the name itself confers nothing — so "root" is just a familiar
|
||||
// alias here, not a backdoor.
|
||||
var AdminNames = map[string]bool{"admin": true, "sysop": true, "root": true}
|
||||
|
||||
// TorURLNames route to the one-shot "fetch a URL over Tor" command (premium).
|
||||
var TorURLNames = map[string]bool{"tor-url": true}
|
||||
|
|
@ -112,6 +113,13 @@ func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
|
|||
// management TUI (operator-gated).
|
||||
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
|
||||
|
||||
// MsgNames route a member-to-member message: `ssh msg@host <user>` leaves a
|
||||
// note in the recipient's BBS inbox (store-and-forward, see the Members plugin).
|
||||
var MsgNames = map[string]bool{"msg": true, "message": true}
|
||||
|
||||
// IsMsgName reports whether the SSH username requests the messaging route.
|
||||
func IsMsgName(u string) bool { return MsgNames[strings.ToLower(u)] }
|
||||
|
||||
// systemReserved are names that don't drive an SSH route but would still
|
||||
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
||||
// infra hostnames — so members may not claim them as account names.
|
||||
|
|
@ -129,7 +137,7 @@ func IsReservedName(name string) bool {
|
|||
n := strings.ToLower(name)
|
||||
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
|
||||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
|
||||
MailNames[n] || FilesAdminNames[n] || systemReserved[n] {
|
||||
MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] || systemReserved[n] {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package auth
|
|||
import "testing"
|
||||
|
||||
func TestIsAdminName(t *testing.T) {
|
||||
for _, name := range []string{"admin", "ADMIN", "sysop"} {
|
||||
for _, name := range []string{"admin", "ADMIN", "sysop", "root"} {
|
||||
if !IsAdminName(name) {
|
||||
t.Errorf("IsAdminName(%q) = false, want true", name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,58 @@ func (c Config) EnsureUserReset(username, email string) (created bool, password
|
|||
return false, pw, nil
|
||||
}
|
||||
|
||||
// EnsureKey registers an SSH public key on the member's Forgejo account so the
|
||||
// key they use for the BBS is also their git push key ("BBS membership is the
|
||||
// git account"). It is idempotent: added is false when the same key material is
|
||||
// already present. A blank key is a no-op. title labels the key in Forgejo.
|
||||
func (c Config) EnsureKey(username, title, pubKey string) (added bool, err error) {
|
||||
if !c.Configured() {
|
||||
return false, fmt.Errorf("forgejo not configured")
|
||||
}
|
||||
pubKey = strings.TrimSpace(pubKey)
|
||||
if pubKey == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Skip if this key (ignoring the trailing comment) is already on the account.
|
||||
if status, resp, e := c.do(http.MethodGet, "/users/"+username+"/keys", nil); e == nil && status == http.StatusOK {
|
||||
var keys []struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if json.Unmarshal([]byte(resp), &keys) == nil {
|
||||
want := keyMaterial(pubKey)
|
||||
for _, k := range keys {
|
||||
if keyMaterial(k.Key) == want {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"title": title, "key": pubKey, "read_only": false})
|
||||
status, resp, err := c.do(http.MethodPost, "/admin/users/"+username+"/keys", body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if status == http.StatusUnprocessableEntity {
|
||||
return false, nil // key already exists (raced or comment differs)
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return false, fmt.Errorf("forgejo add key %q: %d: %s", username, status, truncate(resp, 200))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// keyMaterial returns the type+base64 of an authorized-key line, dropping the
|
||||
// optional comment so the same key compares equal regardless of how it's labeled.
|
||||
func keyMaterial(authorizedKey string) string {
|
||||
f := strings.Fields(strings.TrimSpace(authorizedKey))
|
||||
if len(f) >= 2 {
|
||||
return f[0] + " " + f[1]
|
||||
}
|
||||
return strings.TrimSpace(authorizedKey)
|
||||
}
|
||||
|
||||
// userExists reports whether a Forgejo user with this name is present.
|
||||
func (c Config) userExists(username string) (bool, error) {
|
||||
status, resp, err := c.do(http.MethodGet, "/users/"+username, nil)
|
||||
|
|
|
|||
|
|
@ -174,3 +174,65 @@ func TestEnsureUserNoOpWhenExists(t *testing.T) {
|
|||
t.Fatal("must not POST when the user already exists")
|
||||
}
|
||||
}
|
||||
|
||||
const aliceKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY alice@bbs"
|
||||
|
||||
func TestEnsureKeyAddsWhenMissing(t *testing.T) {
|
||||
var posted map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/users/alice/keys":
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/admin/users/alice/keys":
|
||||
_ = json.NewDecoder(r.Body).Decode(&posted)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":1}`))
|
||||
default:
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Config{BaseURL: srv.URL, Token: "secret"}
|
||||
added, err := c.EnsureKey("alice", "agentbbs", aliceKey)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
if !added {
|
||||
t.Fatal("expected added=true")
|
||||
}
|
||||
if posted["key"] != aliceKey {
|
||||
t.Errorf("posted key = %v", posted["key"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeyIdempotentIgnoringComment(t *testing.T) {
|
||||
posted := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
posted = true
|
||||
}
|
||||
// Same key material, different comment — must be treated as already present.
|
||||
_, _ = w.Write([]byte(`[{"key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY different-comment"}]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := Config{BaseURL: srv.URL, Token: "secret"}
|
||||
added, err := c.EnsureKey("alice", "agentbbs", aliceKey)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
if added {
|
||||
t.Fatal("expected added=false when key material already present")
|
||||
}
|
||||
if posted {
|
||||
t.Fatal("must not POST when the key already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKeyBlankIsNoOp(t *testing.T) {
|
||||
c := Config{BaseURL: "https://git.example.com", Token: "t"}
|
||||
if added, err := c.EnsureKey("alice", "agentbbs", " "); err != nil || added {
|
||||
t.Fatalf("blank key should be a silent no-op, got added=%v err=%v", added, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,21 +19,12 @@ import (
|
|||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
cursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
selStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e2e8f0"))
|
||||
lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
theme = ui.New(ui.Green)
|
||||
bannerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e11d2a"))
|
||||
motdStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("#4ade80")).
|
||||
Foreground(lipgloss.Color("252")).
|
||||
Padding(0, 1)
|
||||
frameStyle = lipgloss.NewStyle().Padding(1, 2)
|
||||
)
|
||||
|
||||
// SessionApp is a hub entry that takes over the terminal — a pod shell, the IRC
|
||||
|
|
@ -170,42 +161,37 @@ func (m Model) View() string {
|
|||
b.WriteString(bannerStyle.Render(m.banner) + "\n\n")
|
||||
}
|
||||
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
|
||||
b.WriteString(titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n")
|
||||
b.WriteString(theme.Title("AgentBBS") + ui.Dim.Render(" · "+who) + "\n")
|
||||
if m.motd != "" {
|
||||
b.WriteString("\n" + motdStyle.Render(m.motd) + "\n")
|
||||
b.WriteString("\n" + theme.Card("", m.motd) + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
row := 0
|
||||
for _, p := range m.plugins {
|
||||
label := p.Title()
|
||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||
label += lockStyle.Render(" [members]")
|
||||
if len(m.plugins) > 0 {
|
||||
b.WriteString(theme.Section("Features") + "\n")
|
||||
for _, p := range m.plugins {
|
||||
badge := ""
|
||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||
badge = ui.Badge(ui.BadgeMuted, "members")
|
||||
}
|
||||
b.WriteString(theme.MenuItem(row == m.cursor, p.Title(), badge, p.Description()))
|
||||
row++
|
||||
}
|
||||
b.WriteString(m.renderRow(row, label, p.Description()))
|
||||
row++
|
||||
}
|
||||
for _, app := range m.apps {
|
||||
label := app.Title
|
||||
if app.Locked != "" {
|
||||
label += lockStyle.Render(" [locked]")
|
||||
if len(m.apps) > 0 {
|
||||
b.WriteString("\n" + theme.Section("Sessions") + "\n")
|
||||
for _, app := range m.apps {
|
||||
badge := ""
|
||||
if app.Locked != "" {
|
||||
badge = ui.Badge(ui.BadgeGold, "locked")
|
||||
}
|
||||
b.WriteString(theme.MenuItem(row == m.cursor, app.Title, badge, app.Description))
|
||||
row++
|
||||
}
|
||||
b.WriteString(m.renderRow(row, label, app.Description))
|
||||
row++
|
||||
}
|
||||
b.WriteString("\n" + dimStyle.Render("↑/↓ move · enter select · ctrl+c back · q quit"))
|
||||
b.WriteString("\n" + ui.KeyBar("↑/↓ move · enter select · ctrl+c back · q quit"))
|
||||
if m.note != "" {
|
||||
b.WriteString("\n" + lockStyle.Render(m.note))
|
||||
b.WriteString("\n" + ui.Danger.Render(m.note))
|
||||
}
|
||||
return frameStyle.Render(b.String())
|
||||
}
|
||||
|
||||
// renderRow renders one menu line with the cursor and dimmed description. The
|
||||
// selected row's cursor and label are highlighted.
|
||||
func (m Model) renderRow(i int, label, desc string) string {
|
||||
cur := " "
|
||||
if i == m.cursor {
|
||||
cur = cursorStyle.Render("❯ ")
|
||||
label = selStyle.Render(label)
|
||||
}
|
||||
return fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(desc))
|
||||
return ui.Frame.Render(b.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ func RunBot(ctx context.Context, c *Client, args []string, in io.Reader, out io.
|
|||
if len(args) > 3 {
|
||||
on = !strings.EqualFold(args[3], "off") && args[3] != "false" && args[3] != "0"
|
||||
}
|
||||
if args[0] == "flag" {
|
||||
if strings.ToLower(args[0]) == "flag" {
|
||||
err = c.Flag(ctx, mailbox, uid, on)
|
||||
} else {
|
||||
err = c.MarkSeen(ctx, mailbox, uid, on)
|
||||
|
|
|
|||
|
|
@ -7,16 +7,19 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// Identity is the acting member and whether they hold the paid membership.
|
||||
// Identity is the acting member. AgentMail is a free benefit of membership, so
|
||||
// having a registered handle is the only requirement; Paid is retained for
|
||||
// tier-aware features (e.g. quotas) but no longer gates access.
|
||||
type Identity struct {
|
||||
Name string // local-part / handle, e.g. "alice"
|
||||
Paid bool // Founding Lifetime Member; mail is gated on this
|
||||
Paid bool // Founding Lifetime Member (informational; does not gate mail)
|
||||
}
|
||||
|
||||
// ErrNotPaid is returned to a non-paid member attempting a mail action.
|
||||
var ErrNotPaid = errors.New("AgentMail is a Founding Lifetime Member feature ($99 one-time) — upgrade: ssh join@bbs.profullstack.com")
|
||||
// ErrNotMember is returned when a caller without a registered handle attempts a
|
||||
// mail action. AgentMail is open to every verified member.
|
||||
var ErrNotMember = errors.New("AgentMail is a member feature — register first: ssh join@bbs.profullstack.com")
|
||||
|
||||
// Client is the ergonomic, paid-gated facade the TUI and bot mode use. Every
|
||||
// Client is the ergonomic, member-gated facade the TUI and bot mode use. Every
|
||||
// method returns plain structs, so the same calls serve humans and agents.
|
||||
type Client struct {
|
||||
t Transport
|
||||
|
|
@ -25,8 +28,8 @@ type Client struct {
|
|||
pageSize int
|
||||
}
|
||||
|
||||
// NewClient builds a paid-gated client. domain is the mail domain (e.g.
|
||||
// mail.profullstack.com); pageSize defaults to 50 when <= 0.
|
||||
// NewClient builds a member-gated client. domain is the email address domain
|
||||
// (e.g. bbs.profullstack.com); pageSize defaults to 50 when <= 0.
|
||||
func NewClient(t Transport, id Identity, domain string, pageSize int) *Client {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
|
|
@ -34,12 +37,12 @@ func NewClient(t Transport, id Identity, domain string, pageSize int) *Client {
|
|||
return &Client{t: t, id: id, domain: domain, pageSize: pageSize}
|
||||
}
|
||||
|
||||
// Address is the member's own mailbox address, e.g. alice@mail.profullstack.com.
|
||||
// Address is the member's own mailbox address, e.g. alice@bbs.profullstack.com.
|
||||
func (c *Client) Address() string { return c.id.Name + "@" + c.domain }
|
||||
|
||||
func (c *Client) gate() error {
|
||||
if c.id.Name == "" || !c.id.Paid {
|
||||
return ErrNotPaid
|
||||
if c.id.Name == "" {
|
||||
return ErrNotMember
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ type IMAPConfig struct {
|
|||
// SMTPUser/SMTPPass default to Username/Password when empty.
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
// Plaintext dials IMAP without TLS. Used only for a co-located backend over
|
||||
// loopback (the Mailu gateway hitting Dovecot directly on 127.0.0.1, bypassing
|
||||
// the front's auth proxy so master-user login works) — the password never
|
||||
// leaves the host. Never enable it for a remote server.
|
||||
Plaintext bool
|
||||
}
|
||||
|
||||
// imapTransport is a Transport backed by a single authenticated IMAP connection
|
||||
|
|
@ -38,7 +43,11 @@ type imapTransport struct {
|
|||
|
||||
// NewIMAPTransport dials the IMAP server, logs in, and returns a Transport.
|
||||
func NewIMAPTransport(cfg IMAPConfig) (Transport, error) {
|
||||
c, err := imapclient.DialTLS(cfg.IMAPAddr, nil)
|
||||
dial := imapclient.DialTLS
|
||||
if cfg.Plaintext {
|
||||
dial = imapclient.DialInsecure
|
||||
}
|
||||
c, err := dial(cfg.IMAPAddr, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ func seeded() *MemoryTransport {
|
|||
}
|
||||
|
||||
func paidClient(t Transport) *Client {
|
||||
return NewClient(t, Identity{Name: "alice", Paid: true}, "mail.profullstack.com", 50)
|
||||
return NewClient(t, Identity{Name: "alice", Paid: true}, "bbs.profullstack.com", 50)
|
||||
}
|
||||
|
||||
func TestParseFormatAddress(t *testing.T) {
|
||||
|
|
@ -45,9 +45,15 @@ func TestValidEmailAndDraft(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestGate(t *testing.T) {
|
||||
c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "mail.profullstack.com", 0)
|
||||
if _, err := c.Inbox(context.Background(), 0); !errors.Is(err, ErrNotPaid) {
|
||||
t.Fatalf("expected ErrNotPaid, got %v", err)
|
||||
// A free member (Paid: false) now has full mail access.
|
||||
c := NewClient(seeded(), Identity{Name: "bob", Paid: false}, "bbs.profullstack.com", 0)
|
||||
if _, err := c.Inbox(context.Background(), 0); err != nil {
|
||||
t.Fatalf("free member should have mail access, got %v", err)
|
||||
}
|
||||
// Only a caller without a registered handle is rejected.
|
||||
anon := NewClient(seeded(), Identity{Name: "", Paid: true}, "bbs.profullstack.com", 0)
|
||||
if _, err := anon.Inbox(context.Background(), 0); !errors.Is(err, ErrNotMember) {
|
||||
t.Fatalf("expected ErrNotMember, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +112,7 @@ func TestSendAndReply(t *testing.T) {
|
|||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
sent, _ := tr.ListMessages(context.Background(), ListOptions{Mailbox: Sent})
|
||||
if len(sent) != 1 || sent[0].From.Address != "alice@mail.profullstack.com" || sent[0].Subject != "Hi" {
|
||||
if len(sent) != 1 || sent[0].From.Address != "alice@bbs.profullstack.com" || sent[0].Subject != "Hi" {
|
||||
t.Fatalf("sent: %+v", sent)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Package mailbox is the BBS-side mail client for Founding Lifetime members: a
|
||||
// transport-agnostic core (read, search, compose, send, flag, delete) with a
|
||||
// Bubble Tea TUI for humans and a line-oriented JSON mode for agents/bots. It
|
||||
// talks to the self-hosted Mailu stack (Dovecot IMAP + Postfix submission) at
|
||||
// mail.profullstack.com / smtp.profullstack.com.
|
||||
// Package mailbox is the BBS-side mail client for members (a free benefit of
|
||||
// membership): a transport-agnostic core (read, search, compose, send, flag,
|
||||
// delete) with a Bubble Tea TUI for humans and a line-oriented JSON mode for
|
||||
// agents/bots. Addresses are <name>@bbs.profullstack.com; it talks to the
|
||||
// self-hosted Mailu stack (Dovecot IMAP + Postfix submission) hosted on
|
||||
// mail.profullstack.com.
|
||||
//
|
||||
// The TS counterpart is @logicsrc/plugin-agentmail; the domain shapes here are
|
||||
// deliberately the same so tooling can move between them.
|
||||
|
|
|
|||
197
internal/mailu/mailu.go
Normal file
197
internal/mailu/mailu.go
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
// Package mailu provisions member mailboxes on the self-hosted Mailu stack via
|
||||
// its admin REST API. Every verified AgentBBS member gets a real mailbox at
|
||||
// <name>@<domain> (e.g. alice@bbs.profullstack.com); the agentbbs gateway then
|
||||
// opens it over IMAP with the Dovecot master user, so members never manage an
|
||||
// IMAP/SMTP password. Mailbox creation is the one thing that must happen up
|
||||
// front, which is what EnsureUser does (idempotently).
|
||||
//
|
||||
// The Mailu admin API listens on the loopback HTTP front (default
|
||||
// http://127.0.0.1:8080) and authenticates with the token set as API_TOKEN in
|
||||
// mailu.env. When no token is configured Configured() reports false and callers
|
||||
// skip provisioning (the address is still shown).
|
||||
//
|
||||
// Config (env):
|
||||
//
|
||||
// AGENTBBS_MAIL_ADMIN_URL Mailu admin base URL (default http://127.0.0.1:8080)
|
||||
// AGENTBBS_MAIL_API_TOKEN Mailu API token (from mailu.env API_TOKEN)
|
||||
// AGENTBBS_MAIL_QUOTA_BYTES per-mailbox quota in bytes (default 1 GiB)
|
||||
package mailu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultQuotaBytes is the per-mailbox storage quota when unset (1 GiB).
|
||||
const DefaultQuotaBytes = 1 << 30
|
||||
|
||||
// Config holds the Mailu admin-API endpoint and credentials.
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
QuotaBytes int64
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// ConfigFromEnv reads the Mailu admin settings from the environment.
|
||||
func ConfigFromEnv() Config {
|
||||
q, _ := strconv.ParseInt(os.Getenv("AGENTBBS_MAIL_QUOTA_BYTES"), 10, 64)
|
||||
if q <= 0 {
|
||||
q = DefaultQuotaBytes
|
||||
}
|
||||
base := os.Getenv("AGENTBBS_MAIL_ADMIN_URL")
|
||||
if base == "" {
|
||||
base = "http://127.0.0.1:8080"
|
||||
}
|
||||
return Config{
|
||||
BaseURL: strings.TrimRight(base, "/"),
|
||||
Token: os.Getenv("AGENTBBS_MAIL_API_TOKEN"),
|
||||
QuotaBytes: q,
|
||||
HTTP: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Client talks to the Mailu admin REST API.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// New builds a client. NewFromEnv is the usual entry point.
|
||||
func New(cfg Config) *Client {
|
||||
if cfg.HTTP == nil {
|
||||
cfg.HTTP = &http.Client{Timeout: 15 * time.Second}
|
||||
}
|
||||
if cfg.QuotaBytes <= 0 {
|
||||
cfg.QuotaBytes = DefaultQuotaBytes
|
||||
}
|
||||
return &Client{cfg: cfg}
|
||||
}
|
||||
|
||||
// NewFromEnv builds a client from the environment.
|
||||
func NewFromEnv() *Client { return New(ConfigFromEnv()) }
|
||||
|
||||
// Configured reports whether mailboxes can actually be provisioned.
|
||||
func (c *Client) Configured() bool {
|
||||
return c != nil && c.cfg.Token != "" && c.cfg.BaseURL != ""
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.cfg.BaseURL+"/api/v1"+path, rdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Mailu authenticates the admin API with the raw token in Authorization.
|
||||
req.Header.Set("Authorization", c.cfg.Token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
return c.cfg.HTTP.Do(req)
|
||||
}
|
||||
|
||||
// UserExists reports whether email already has a mailbox.
|
||||
func (c *Client) UserExists(ctx context.Context, email string) (bool, error) {
|
||||
resp, err := c.do(ctx, http.MethodGet, "/user/"+email, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
return true, nil
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("mailu user lookup %s: %s", email, resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureUser creates email@... if it doesn't exist. It is idempotent: an
|
||||
// existing mailbox (or an "already exists" create response) is success. The
|
||||
// generated password is unused by members — the gateway master user opens every
|
||||
// mailbox — but Mailu requires one at creation time.
|
||||
func (c *Client) EnsureUser(ctx context.Context, localPart, domain string) error {
|
||||
if !c.Configured() {
|
||||
return fmt.Errorf("mailu not configured")
|
||||
}
|
||||
email := localPart + "@" + domain
|
||||
exists, err := c.UserExists(ctx, email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
pw, err := randomPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"email": email,
|
||||
"raw_password": pw,
|
||||
"comment": "agentbbs member",
|
||||
"quota_bytes": c.cfg.QuotaBytes,
|
||||
"enabled": true,
|
||||
}
|
||||
resp, err := c.do(ctx, http.MethodPost, "/user", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
// A concurrent create / pre-existing mailbox is fine.
|
||||
if resp.StatusCode == http.StatusConflict ||
|
||||
strings.Contains(strings.ToLower(string(b)), "already exists") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("mailu create user %s: %s: %s", email, resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
// SetPassword sets the mailbox password (so the member can log into webmail).
|
||||
// The gateway opens mailboxes via the Dovecot master user and never needs this,
|
||||
// but webmail (Roundcube) requires the member to have a known password.
|
||||
func (c *Client) SetPassword(ctx context.Context, localPart, domain, password string) error {
|
||||
if !c.Configured() {
|
||||
return fmt.Errorf("mailu not configured")
|
||||
}
|
||||
email := localPart + "@" + domain
|
||||
resp, err := c.do(ctx, http.MethodPatch, "/user/"+email, map[string]any{"raw_password": password})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("mailu set password %s: %s: %s", email, resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
func randomPassword() (string, error) {
|
||||
var b [24]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b[:]), nil
|
||||
}
|
||||
124
internal/mailu/mailu_test.go
Normal file
124
internal/mailu/mailu_test.go
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
package mailu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigured(t *testing.T) {
|
||||
if New(Config{BaseURL: "http://x"}).Configured() {
|
||||
t.Fatal("no token should be unconfigured")
|
||||
}
|
||||
if !New(Config{BaseURL: "http://x", Token: "tok"}).Configured() {
|
||||
t.Fatal("token should be configured")
|
||||
}
|
||||
var nilc *Client
|
||||
if nilc.Configured() {
|
||||
t.Fatal("nil client must be unconfigured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUserCreatesWhenMissing(t *testing.T) {
|
||||
var created map[string]any
|
||||
var sawToken string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawToken = r.Header.Get("Authorization")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/user/"):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/user":
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &created)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(Config{BaseURL: srv.URL, Token: "secret-tok"})
|
||||
if err := c.EnsureUser(context.Background(), "alice", "bbs.profullstack.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sawToken != "secret-tok" {
|
||||
t.Fatalf("token header = %q", sawToken)
|
||||
}
|
||||
if created["email"] != "alice@bbs.profullstack.com" {
|
||||
t.Fatalf("created email = %v", created["email"])
|
||||
}
|
||||
if created["raw_password"] == nil || created["raw_password"] == "" {
|
||||
t.Fatal("expected a generated password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUserIdempotentWhenExists(t *testing.T) {
|
||||
posted := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
posted = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(Config{BaseURL: srv.URL, Token: "t"})
|
||||
if err := c.EnsureUser(context.Background(), "bob", "bbs.profullstack.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if posted {
|
||||
t.Fatal("should not POST when the mailbox already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUserConflictIsSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = io.WriteString(w, `{"message":"already exists"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(Config{BaseURL: srv.URL, Token: "t"})
|
||||
if err := c.EnsureUser(context.Background(), "carol", "bbs.profullstack.com"); err != nil {
|
||||
t.Fatalf("conflict should be treated as success, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureUserUnconfigured(t *testing.T) {
|
||||
if err := New(Config{}).EnsureUser(context.Background(), "x", "y"); err == nil {
|
||||
t.Fatal("expected error when unconfigured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPassword(t *testing.T) {
|
||||
var method, path string
|
||||
var body map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method, path = r.Method, r.URL.Path
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New(Config{BaseURL: srv.URL, Token: "t"})
|
||||
if err := c.SetPassword(context.Background(), "alice", "bbs.profullstack.com", "hunter2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if method != http.MethodPatch || path != "/api/v1/user/alice@bbs.profullstack.com" {
|
||||
t.Fatalf("got %s %s", method, path)
|
||||
}
|
||||
if body["raw_password"] != "hunter2" {
|
||||
t.Fatalf("raw_password = %v", body["raw_password"])
|
||||
}
|
||||
}
|
||||
|
|
@ -173,15 +173,17 @@ func parseRange(spec string) (low, high int64) {
|
|||
if len(parts) == 1 {
|
||||
h, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
h = math.MaxInt64
|
||||
return 0, h
|
||||
return 0, 0 // malformed — empty range instead of all articles
|
||||
}
|
||||
return h, h
|
||||
}
|
||||
l, _ := strconv.ParseInt(parts[0], 10, 64)
|
||||
l, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0 // malformed — empty range
|
||||
}
|
||||
h, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
h = math.MaxInt64
|
||||
return 0, 0 // malformed — empty range
|
||||
}
|
||||
return l, h
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,15 +8,16 @@ import (
|
|||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/dustin/go-nntp"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/ui"
|
||||
)
|
||||
|
||||
var (
|
||||
nTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#c084fc"))
|
||||
nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(lipgloss.Color("#38bdf8"))
|
||||
nMeta = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
nFrom = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
nErr = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
nHint = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
|
||||
theme = ui.New(ui.Purple)
|
||||
nSel = lipgloss.NewStyle().Foreground(lipgloss.Color("#0b1020")).Background(ui.Cyan)
|
||||
nMeta = ui.Dim
|
||||
nFrom = lipgloss.NewStyle().Foreground(ui.Green)
|
||||
nErr = ui.Danger
|
||||
)
|
||||
|
||||
// RunReader connects the member to the loopback NNTP server and drives the
|
||||
|
|
@ -311,7 +312,7 @@ func (m *model) frame(header, body, hint string) string {
|
|||
status = "\n" + nMeta.Render(m.status)
|
||||
}
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(
|
||||
nTitle.Render(header) + "\n\n" + body + status + "\n\n" + nHint.Render(hint))
|
||||
theme.Title(header) + "\n\n" + body + status + "\n\n" + ui.KeyBar(hint))
|
||||
}
|
||||
|
||||
func (m *model) viewGroups() string {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,14 @@ type Context struct {
|
|||
DataDir string
|
||||
// AssetsDir is the read-only platform assets tree (wads, binaries).
|
||||
AssetsDir string
|
||||
// Host is the BBS hostname (e.g. bbs.profullstack.com), for building
|
||||
// member homepage URLs (https://Host/~name) and similar links.
|
||||
Host string
|
||||
// Term is the client PTY's terminal type (e.g. xterm-256color). Needed by
|
||||
// sandboxed ncurses games (Space Invaders, Pac-Man, Tetris, Moon Patrol),
|
||||
// which call initscr() and fail with "Error opening terminal" if TERM is
|
||||
// unset — the systemd daemon environment has no TERM to inherit.
|
||||
Term string
|
||||
}
|
||||
|
||||
// Plugin is the only integration point between a feature and the hub.
|
||||
|
|
|
|||
|
|
@ -13,8 +13,11 @@
|
|||
package pods
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -87,6 +90,69 @@ func (m *Manager) hasMount(name, dest string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// hasImage reports whether the named container is running the given image.
|
||||
// Used to roll out a new pod image: a mismatch triggers an idle recreate so
|
||||
// members pick up added tooling without losing their home volume. A blank or
|
||||
// unresolvable image name is treated as a match (never heal on uncertainty).
|
||||
func (m *Manager) hasImage(name, image string) bool {
|
||||
if image == "" {
|
||||
return true
|
||||
}
|
||||
out, err := exec.Command(m.engine, "container", "inspect", "-f", "{{.ImageName}}", name).Output()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
got := strings.TrimSpace(string(out))
|
||||
// Normalize: inspect may report "localhost/agentbbs-pod:latest" while m.image
|
||||
// is the same; also tolerate the docker.io/library/ prefix podman adds.
|
||||
norm := func(s string) string {
|
||||
s = strings.TrimPrefix(s, "docker.io/library/")
|
||||
s = strings.TrimPrefix(s, "docker.io/")
|
||||
s = strings.TrimPrefix(s, "localhost/")
|
||||
return s
|
||||
}
|
||||
return norm(got) == norm(image)
|
||||
}
|
||||
|
||||
// agentDir is the host directory bind-mounted into a pod at /run/agentbbs-agent,
|
||||
// where Attach drops a forwarded SSH-agent socket. Derived as <data>/agent/<user>
|
||||
// (a sibling of the users dir). Empty — disabling agent forwarding — when the
|
||||
// users dir isn't configured or the directory can't be created.
|
||||
func (m *Manager) agentDir(user string) string {
|
||||
if m.usersDir == "" {
|
||||
return ""
|
||||
}
|
||||
d := filepath.Join(filepath.Dir(m.usersDir), "agent", unsafeName.ReplaceAllString(strings.ToLower(user), "-"))
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return ""
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// startAgent forwards the connecting client's SSH agent into the member's pod:
|
||||
// it listens on a fresh unix socket in the bind-mounted agent dir and proxies
|
||||
// connections back over the SSH session. Returns the in-pod SSH_AUTH_SOCK path
|
||||
// and a cleanup func, or "" when forwarding can't be set up (no agent dir / no
|
||||
// socket). With this, `git push git@git.profullstack.com` inside the pod uses
|
||||
// the member's own key — nothing is copied into the pod.
|
||||
func (m *Manager) startAgent(s ssh.Session, user string) (sock string, cleanup func()) {
|
||||
dir := m.agentDir(user)
|
||||
if dir == "" {
|
||||
return "", func() {}
|
||||
}
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
fname := "agent-" + hex.EncodeToString(b[:]) + ".sock"
|
||||
hostSock := filepath.Join(dir, fname)
|
||||
_ = os.Remove(hostSock)
|
||||
l, err := net.Listen("unix", hostSock)
|
||||
if err != nil {
|
||||
return "", func() {}
|
||||
}
|
||||
go ssh.ForwardAgentConnections(l, s)
|
||||
return "/run/agentbbs-agent/" + fname, func() { _ = l.Close(); _ = os.Remove(hostSock) }
|
||||
}
|
||||
|
||||
// Engine reports the active container engine.
|
||||
func (m *Manager) Engine() string { return m.engine }
|
||||
|
||||
|
|
@ -102,6 +168,9 @@ func (m *Manager) ensure(user string) (string, error) {
|
|||
// Bind the host's public_html into the pod so a member's edits at
|
||||
// ~/public_html are exactly what Caddy serves at <name>.<host>.
|
||||
_, pubSpec := m.publicHTMLMount(user)
|
||||
// Bind a per-user agent dir into the pod; Attach drops a forwarded SSH-agent
|
||||
// socket here so `git push` uses the member's own key (see startAgent).
|
||||
agentDir := m.agentDir(user)
|
||||
if m.engine == "docker" {
|
||||
// Under docker the pod runs as uid 1000 (never container root), so the
|
||||
// named home volume — and the bind-mounted public_html — must be owned
|
||||
|
|
@ -131,11 +200,17 @@ func (m *Manager) ensure(user string) (string, error) {
|
|||
m.mu.Lock()
|
||||
idle := m.attached[name] == 0
|
||||
m.mu.Unlock()
|
||||
if pubSpec != "" && idle && !m.hasMount(name, "/home/dev/public_html") {
|
||||
_ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate with the bind
|
||||
// Recreate an idle pod when it's missing the public_html bind OR is
|
||||
// running an out-of-date image (e.g. a new pod image with added tooling).
|
||||
// The home volume persists across rm, so member data is kept; a busy pod
|
||||
// heals on its next idle attach instead.
|
||||
needsHeal := idle && ((pubSpec != "" && !m.hasMount(name, "/home/dev/public_html")) ||
|
||||
(agentDir != "" && !m.hasMount(name, "/run/agentbbs-agent")) ||
|
||||
!m.hasImage(name, m.image))
|
||||
if needsHeal {
|
||||
_ = exec.Command(m.engine, "rm", "-f", name).Run() // fall through to recreate
|
||||
} else {
|
||||
_ = exec.Command(m.engine, "start", name).Run() // no-op if running
|
||||
m.tuneApt(name)
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -154,6 +229,9 @@ func (m *Manager) ensure(user string) (string, error) {
|
|||
if pubSpec != "" {
|
||||
args = append(args, "-v", pubSpec)
|
||||
}
|
||||
if agentDir != "" {
|
||||
args = append(args, "-v", agentDir+":/run/agentbbs-agent")
|
||||
}
|
||||
if m.engine == "docker" {
|
||||
// Rootful docker: a breakout is host-root, so refuse to hand out
|
||||
// container root — run as uid 1000 with no caps and no privilege
|
||||
|
|
@ -178,28 +256,9 @@ func (m *Manager) ensure(user string) (string, error) {
|
|||
if err != nil {
|
||||
return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
m.tuneApt(name)
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// tuneApt makes apt usable inside the hardened pod. apt drops privileges to
|
||||
// the _apt user for downloads (setgroups/setegid/seteuid), which needs
|
||||
// CAP_SETUID/CAP_SETGID/CAP_CHOWN — caps we intentionally drop (cap-drop ALL).
|
||||
// Rather than re-grant those to the whole container, disable apt's download
|
||||
// sandbox so package management runs as the pod's (rootless-mapped) root.
|
||||
//
|
||||
// Only applies to the podman/container-root path; under docker the pod runs as
|
||||
// uid 1000 and can't write /etc/apt (apt isn't usable there by design). Failure
|
||||
// is non-fatal: a missing config just means the user sees the old apt errors.
|
||||
func (m *Manager) tuneApt(name string) {
|
||||
if m.engine == "docker" {
|
||||
return
|
||||
}
|
||||
_ = exec.Command(m.engine, "exec", "--user", "root", name,
|
||||
"sh", "-c", `printf 'APT::Sandbox::User "root";\n' > /etc/apt/apt.conf.d/00no-sandbox`,
|
||||
).Run()
|
||||
}
|
||||
|
||||
// Attach provisions the pod and wires the SSH session to a shell inside it.
|
||||
// Blocks until the shell exits or the session closes.
|
||||
func (m *Manager) Attach(s ssh.Session, user string) error {
|
||||
|
|
@ -212,14 +271,22 @@ func (m *Manager) Attach(s ssh.Session, user string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Forward the client's SSH agent (ssh -A) into the pod so git push uses the
|
||||
// member's own key. No-op unless the client requested forwarding.
|
||||
execEnv := []string{"-e", "TERM=" + ptyReq.Term}
|
||||
if ssh.AgentRequested(s) {
|
||||
if sock, cleanup := m.startAgent(s, user); sock != "" {
|
||||
defer cleanup()
|
||||
execEnv = append(execEnv, "-e", "SSH_AUTH_SOCK="+sock)
|
||||
}
|
||||
}
|
||||
|
||||
shell := env("AGENTBBS_POD_SHELL", "/bin/bash")
|
||||
cmd := exec.Command(m.engine, "exec", "-it",
|
||||
"-e", "TERM="+ptyReq.Term,
|
||||
name, shell, "-l")
|
||||
cmd := exec.Command(m.engine, append(append([]string{"exec", "-it"}, execEnv...), name, shell, "-l")...)
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
// busybox-ish images may lack bash
|
||||
cmd = exec.Command(m.engine, "exec", "-it", "-e", "TERM="+ptyReq.Term, name, "/bin/sh", "-l")
|
||||
cmd = exec.Command(m.engine, append(append([]string{"exec", "-it"}, execEnv...), name, "/bin/sh", "-l")...)
|
||||
f, err = pty.Start(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pods: attach failed: %w", err)
|
||||
|
|
|
|||
|
|
@ -87,16 +87,45 @@ func Classify(raw string) (Kind, error) {
|
|||
|
||||
// isBlockedIP reports whether an address must not be dialed: loopback,
|
||||
// link-local (incl. the 169.254.169.254 cloud-metadata endpoint), private
|
||||
// (RFC1918 / fc00::/7), multicast, or unspecified.
|
||||
// (RFC1918 / fc00::/7), multicast, unspecified, shared/reserved ranges
|
||||
// (100.64.0.0/10, 198.18.0.0/15), and documentation/example networks.
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return ip == nil ||
|
||||
if ip == nil ||
|
||||
ip.IsLoopback() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified() ||
|
||||
ip.IsPrivate()
|
||||
ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
// Shared address space (Carrier-Grade NAT / RFC 6598)
|
||||
// 100.64.0.0/10
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
b := ip4[0]
|
||||
// 100.64.0.0 - 100.127.255.255
|
||||
if b == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
|
||||
return true
|
||||
}
|
||||
// Benchmarking (RFC 2544) 198.18.0.0/15
|
||||
// 198.18.0.0 - 198.19.255.255
|
||||
if b == 198 && (ip4[1] == 18 || ip4[1] == 19) {
|
||||
return true
|
||||
}
|
||||
// Documentation / example (RFC 5737)
|
||||
// 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24
|
||||
if b == 192 && ip4[1] == 0 && ip4[2] == 2 {
|
||||
return true
|
||||
}
|
||||
if b == 198 && ip4[1] == 51 && ip4[2] == 100 {
|
||||
return true
|
||||
}
|
||||
if b == 203 && ip4[1] == 0 && ip4[2] == 113 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// guardURL validates scheme and resolves the host, rejecting any URL that
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ package store
|
|||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
|
@ -41,10 +43,22 @@ func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
|||
u.EmailVerified = verified != 0
|
||||
u.Premium = premium != 0
|
||||
u.Banned = banned != 0
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
if t, err := time.Parse(time.RFC3339, created); err == nil {
|
||||
u.CreatedAt = t
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Message is one member-to-member note in the store-and-forward inbox.
|
||||
type Message struct {
|
||||
ID int64
|
||||
From string
|
||||
To string
|
||||
Body string
|
||||
Read bool
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Score is one leaderboard entry.
|
||||
type Score struct {
|
||||
User string
|
||||
|
|
@ -100,6 +114,21 @@ type Store interface {
|
|||
AddChat(userID int64, username, role, text string) error
|
||||
RecentChats(username string, n int) ([]ChatMessage, error)
|
||||
|
||||
// Member-to-member messaging (store-and-forward inbox).
|
||||
|
||||
// SendMessage leaves a note from→to in the recipient's inbox.
|
||||
SendMessage(from, to, body string) error
|
||||
// Inbox returns up to n messages addressed to username, newest first.
|
||||
Inbox(username string, n int) ([]Message, error)
|
||||
// UnreadCount reports how many unread messages username has waiting.
|
||||
UnreadCount(username string) (int, error)
|
||||
// MarkRead marks the given message ids read (scoped to username so a member
|
||||
// can only clear their own mail). Empty ids is a no-op.
|
||||
MarkRead(username string, ids []int64) error
|
||||
// OnlineUsers reports the set of usernames with an open session (no
|
||||
// ended_at), for the members directory presence dots.
|
||||
OnlineUsers() (map[string]bool, error)
|
||||
|
||||
// Custom domains mapped to a member's homepage (public_html).
|
||||
// MapDomain binds domain→username, returning ErrDomainTaken if it is
|
||||
// already claimed by someone else (re-binding to the same owner is a no-op).
|
||||
|
|
@ -506,6 +535,15 @@ CREATE TABLE IF NOT EXISTS files_settings (
|
|||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_user TEXT NOT NULL,
|
||||
to_user TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_user, id DESC);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
|
|
@ -516,8 +554,12 @@ func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
|||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return User{ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("get user id after insert: %w", err)
|
||||
}
|
||||
return User{
|
||||
ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil
|
||||
case err != nil:
|
||||
return User{}, err
|
||||
}
|
||||
|
|
@ -721,6 +763,75 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error)
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) SendMessage(from, to, body string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO messages (from_user, to_user, body) VALUES (?,?,?)`,
|
||||
from, to, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) Inbox(username string, n int) ([]Message, error) {
|
||||
if n <= 0 {
|
||||
n = 50
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, from_user, to_user, body, read, created_at
|
||||
FROM messages WHERE to_user = ? ORDER BY id DESC LIMIT ?`, username, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Message
|
||||
for rows.Next() {
|
||||
var m Message
|
||||
var read int
|
||||
var at string
|
||||
if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Body, &read, &at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Read = read != 0
|
||||
m.At, _ = time.Parse(time.RFC3339, at)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UnreadCount(username string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE to_user = ? AND read = 0`, username).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) MarkRead(username string, ids []int64) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
q := `UPDATE messages SET read = 1 WHERE to_user = ? AND id IN (?` + strings.Repeat(",?", len(ids)-1) + `)`
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, username)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
_, err := s.db.Exec(q, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) OnlineUsers() (map[string]bool, error) {
|
||||
rows, err := s.db.Query(`SELECT DISTINCT username FROM sessions WHERE ended_at IS NULL`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
online := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
online[strings.ToLower(name)] = true
|
||||
}
|
||||
return online, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) MapDomain(domain, username string) error {
|
||||
var owner string
|
||||
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&owner)
|
||||
|
|
|
|||
82
internal/store/store_messages_test.go
Normal file
82
internal/store/store_messages_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMessagingRoundtrip(t *testing.T) {
|
||||
st := openTest(t)
|
||||
|
||||
_, _ = st.EnsureUser("alice", "member", "SHA256:aaa")
|
||||
_, _ = st.EnsureUser("bob", "member", "SHA256:bbb")
|
||||
|
||||
if n, err := st.UnreadCount("bob"); err != nil || n != 0 {
|
||||
t.Fatalf("fresh unread: n=%d err=%v", n, err)
|
||||
}
|
||||
|
||||
if err := st.SendMessage("alice", "bob", "hey, c4 tonight?"); err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
if err := st.SendMessage("alice", "bob", "second note"); err != nil {
|
||||
t.Fatalf("send2: %v", err)
|
||||
}
|
||||
|
||||
n, err := st.UnreadCount("bob")
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("unread after send: n=%d err=%v", n, err)
|
||||
}
|
||||
|
||||
inbox, err := st.Inbox("bob", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("inbox: %v", err)
|
||||
}
|
||||
if len(inbox) != 2 {
|
||||
t.Fatalf("want 2 messages, got %d", len(inbox))
|
||||
}
|
||||
// Newest first.
|
||||
if inbox[0].Body != "second note" || inbox[0].From != "alice" || inbox[0].To != "bob" {
|
||||
t.Fatalf("unexpected newest message: %+v", inbox[0])
|
||||
}
|
||||
|
||||
// Mark only the first read; the other stays unread.
|
||||
if err := st.MarkRead("bob", []int64{inbox[0].ID}); err != nil {
|
||||
t.Fatalf("markread: %v", err)
|
||||
}
|
||||
if n, _ := st.UnreadCount("bob"); n != 1 {
|
||||
t.Fatalf("want 1 unread after partial read, got %d", n)
|
||||
}
|
||||
|
||||
// MarkRead is scoped to the recipient: alice can't clear bob's mail.
|
||||
if err := st.MarkRead("alice", []int64{inbox[1].ID}); err != nil {
|
||||
t.Fatalf("markread other: %v", err)
|
||||
}
|
||||
if n, _ := st.UnreadCount("bob"); n != 1 {
|
||||
t.Fatalf("cross-user markread leaked: unread=%d", n)
|
||||
}
|
||||
|
||||
// Empty ids is a no-op.
|
||||
if err := st.MarkRead("bob", nil); err != nil {
|
||||
t.Fatalf("markread empty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlineUsers(t *testing.T) {
|
||||
st := openTest(t)
|
||||
|
||||
u, _ := st.EnsureUser("carol", "member", "SHA256:ccc")
|
||||
id, _ := st.RecordSession(u.ID, "carol", "1.2.3.4", "hub")
|
||||
|
||||
online, err := st.OnlineUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("online: %v", err)
|
||||
}
|
||||
if !online["carol"] {
|
||||
t.Fatal("carol should be online while her session is open")
|
||||
}
|
||||
|
||||
if err := st.EndSession(id); err != nil {
|
||||
t.Fatalf("end: %v", err)
|
||||
}
|
||||
online, _ = st.OnlineUsers()
|
||||
if online["carol"] {
|
||||
t.Fatal("carol should be offline after her session ends")
|
||||
}
|
||||
}
|
||||
159
internal/ui/theme.go
Normal file
159
internal/ui/theme.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// Package ui is the shared TUI theme for AgentBBS: one palette and a small set
|
||||
// of structural widgets (cards, menu rows, status badges, key bars) so every
|
||||
// screen — the hub and each plugin — looks like part of the same product.
|
||||
//
|
||||
// Screens keep their own accent color for identity (the hub is green, the
|
||||
// arcade amber, the newsreader purple) by constructing a Theme with that
|
||||
// accent; the layout primitives are shared.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Palette — the only colors any screen should reach for.
|
||||
const (
|
||||
Green = lipgloss.Color("#4ade80")
|
||||
Cyan = lipgloss.Color("#38bdf8")
|
||||
Blue = lipgloss.Color("#60a5fa")
|
||||
Gold = lipgloss.Color("#fbbf24")
|
||||
Purple = lipgloss.Color("#c084fc")
|
||||
Red = lipgloss.Color("#f87171")
|
||||
|
||||
white = lipgloss.Color("#e2e8f0")
|
||||
text = lipgloss.Color("252")
|
||||
muted = lipgloss.Color("245")
|
||||
faint = lipgloss.Color("240")
|
||||
)
|
||||
|
||||
// Structural styles shared by every screen.
|
||||
var (
|
||||
// Frame is the outer padding every top-level View should wrap itself in.
|
||||
Frame = lipgloss.NewStyle().Padding(1, 2)
|
||||
// Dim is for secondary text (descriptions, metadata).
|
||||
Dim = lipgloss.NewStyle().Foreground(muted)
|
||||
// Body is primary readable text.
|
||||
Body = lipgloss.NewStyle().Foreground(text)
|
||||
// Danger is for errors and warnings.
|
||||
Danger = lipgloss.NewStyle().Foreground(Red)
|
||||
|
||||
selStyle = lipgloss.NewStyle().Bold(true).Foreground(white)
|
||||
hintText = lipgloss.NewStyle().Foreground(faint)
|
||||
keyText = lipgloss.NewStyle().Bold(true).Foreground(muted)
|
||||
)
|
||||
|
||||
// Theme carries one screen's accent color and renders the shared widgets in it.
|
||||
type Theme struct{ Accent lipgloss.Color }
|
||||
|
||||
// New returns a theme that tints titles, sections, card borders, cursors, and
|
||||
// selected rows with accent (use a palette color).
|
||||
func New(accent lipgloss.Color) Theme { return Theme{Accent: accent} }
|
||||
|
||||
func (t Theme) accentStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(t.Accent)
|
||||
}
|
||||
|
||||
// Title renders the screen's main heading.
|
||||
func (t Theme) Title(s string) string { return t.accentStyle().Render(s) }
|
||||
|
||||
// Section renders an upper-cased sub-heading inside a screen.
|
||||
func (t Theme) Section(s string) string { return t.accentStyle().Render(strings.ToUpper(s)) }
|
||||
|
||||
// Card frames body in a rounded border tinted with the accent. A non-empty
|
||||
// title is rendered as a section header at the top of the card.
|
||||
func (t Theme) Card(title, body string) string {
|
||||
if title != "" {
|
||||
body = t.Section(title) + "\n\n" + body
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(t.Accent).
|
||||
Padding(1, 2).
|
||||
Render(body)
|
||||
}
|
||||
|
||||
// Row renders one selectable menu line: an accent cursor and bold label when
|
||||
// selected, with a dimmed description on the next line. An empty desc yields a
|
||||
// single-line row. The returned string ends in a newline.
|
||||
func (t Theme) Row(selected bool, label, desc string) string {
|
||||
cur := " "
|
||||
if selected {
|
||||
cur = t.accentStyle().Render("❯ ")
|
||||
label = selStyle.Render(label)
|
||||
}
|
||||
row := cur + label + "\n"
|
||||
if desc != "" {
|
||||
row += " " + Dim.Render(desc) + "\n"
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// MenuItem renders one polished menu line shared by the hub and the plugin
|
||||
// menus (PRD §4.1): an accent cursor and bold label when selected, an optional
|
||||
// status badge after the label, and — to keep long menus uncluttered — the
|
||||
// description shown only for the focused row. The result ends in a newline.
|
||||
func (t Theme) MenuItem(selected bool, label, badge, desc string) string {
|
||||
name := Body.Render(label)
|
||||
cur := " "
|
||||
if selected {
|
||||
name = selStyle.Render(label)
|
||||
cur = t.accentStyle().Render("❯ ")
|
||||
}
|
||||
if badge != "" {
|
||||
name += " " + badge
|
||||
}
|
||||
out := cur + name + "\n"
|
||||
if selected && desc != "" {
|
||||
out += " " + Dim.Render(desc) + "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Badge variants.
|
||||
const (
|
||||
BadgeOK = "ok"
|
||||
BadgeInfo = "info"
|
||||
BadgeGold = "gold"
|
||||
BadgeWarn = "warn"
|
||||
BadgeMuted = "muted"
|
||||
)
|
||||
|
||||
// Badge renders a small filled status tag, e.g. Badge(BadgeOK, "guests welcome").
|
||||
func Badge(variant, label string) string {
|
||||
var fg, bg lipgloss.Color
|
||||
switch variant {
|
||||
case BadgeOK:
|
||||
fg, bg = lipgloss.Color("#052e16"), Green
|
||||
case BadgeInfo:
|
||||
fg, bg = lipgloss.Color("#082f49"), Cyan
|
||||
case BadgeGold:
|
||||
fg, bg = lipgloss.Color("#451a03"), Gold
|
||||
case BadgeWarn:
|
||||
fg, bg = lipgloss.Color("#450a0a"), Red
|
||||
default:
|
||||
fg, bg = lipgloss.Color("#0b1020"), muted
|
||||
}
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(fg).Background(bg).Padding(0, 1).Render(label)
|
||||
}
|
||||
|
||||
// KeyBar renders a footer hint, emphasizing the key token of each segment. It
|
||||
// accepts the conventional " · "-separated form ("↑/↓ move · enter select ·
|
||||
// q quit") so call sites read naturally; the first word of each segment is
|
||||
// brightened as the key.
|
||||
func KeyBar(s string) string {
|
||||
segs := strings.Split(s, "·")
|
||||
for i, seg := range segs {
|
||||
seg = strings.TrimSpace(seg)
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if parts := strings.SplitN(seg, " ", 2); len(parts) == 2 {
|
||||
segs[i] = keyText.Render(parts[0]) + hintText.Render(" "+parts[1])
|
||||
} else {
|
||||
segs[i] = keyText.Render(seg)
|
||||
}
|
||||
}
|
||||
return strings.Join(segs, hintText.Render(" · "))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue