mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
Autonomous deploy + free-pod/Premium-email membership
Deploy automation (idempotent, runs on every deploy): - .github/workflows/deploy.yml: push to main/master (or dispatch) SSHes to the droplet and re-runs setup.sh; deploys the pushed branch; smoke-tests :22. - scripts/self-update.sh + agentbbs-update.timer: autonomous backstop that redeploys only when origin advances. - setup.sh hardened: flock, fetch+reset (survives force-push), fixed the always-skipped arcade asset fetch path. Membership model: - Free, email-verified members get their own Docker pod (pod@ paywall removed) and a /~name homepage (seeded at join@). - join@ is now interactive: email -> emailed 6-digit code -> enter code. - Premium ($10 one-time, lifetime via CoinPay) grants a personal <name>@host email (new internal/forwardemail; forwardemail.net aliases) and custom domains (domain@ gated to Premium). - ensurePremium() silently verifies/grants/provisions on hub login, join@, and domain@. New-signup details emailed to AGENTBBS_SIGNUP_NOTIFY (subject "bbs"). Store: User.Premium + premium/premium_ref cols, ConfirmEmailCode, GrantPremium. Tests: store_premium_test.go, forwardemail_test.go. Build/vet/gofmt/test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1086d57a4d
commit
3230807421
11 changed files with 900 additions and 101 deletions
91
internal/forwardemail/forwardemail.go
Normal file
91
internal/forwardemail/forwardemail.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Package forwardemail provisions members' personal @bbs email addresses by
|
||||
// creating aliases on forwardemail.net (https://forwardemail.net) via its REST
|
||||
// API. A premium member gets <username>@<domain> forwarded to the real email
|
||||
// they verified at join@. When unconfigured (no API key) Configured() reports
|
||||
// false and callers just display the address without creating it.
|
||||
//
|
||||
// Config (env):
|
||||
//
|
||||
// AGENTBBS_FORWARDEMAIL_API_KEY forwardemail.net API key (HTTP basic user)
|
||||
// AGENTBBS_FORWARDEMAIL_DOMAIN alias domain (defaults to the BBS host)
|
||||
// AGENTBBS_WEBMAIL_URL webmail interface URL shown to members
|
||||
package forwardemail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiBase = "https://api.forwardemail.net/v1"
|
||||
|
||||
// Config holds the forwardemail.net credentials and the alias domain.
|
||||
type Config struct {
|
||||
APIKey string
|
||||
Domain string
|
||||
Webmail string
|
||||
}
|
||||
|
||||
// ConfigFromEnv reads the forwardemail settings from the environment.
|
||||
func ConfigFromEnv() Config {
|
||||
return Config{
|
||||
APIKey: os.Getenv("AGENTBBS_FORWARDEMAIL_API_KEY"),
|
||||
Domain: os.Getenv("AGENTBBS_FORWARDEMAIL_DOMAIN"),
|
||||
Webmail: os.Getenv("AGENTBBS_WEBMAIL_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
// Configured reports whether aliases can actually be created.
|
||||
func (c Config) Configured() bool { return c.APIKey != "" && c.Domain != "" }
|
||||
|
||||
// WebmailURL is the webmail interface members use to read their mail (may be "").
|
||||
func (c Config) WebmailURL() string { return c.Webmail }
|
||||
|
||||
// Address is the personal email for a username, e.g. alice@bbs.profullstack.com.
|
||||
func (c Config) Address(localPart string) string { return localPart + "@" + c.Domain }
|
||||
|
||||
// CreateAlias creates (or confirms) localPart@Domain forwarding to recipient.
|
||||
// It is idempotent: an "already exists" response is treated as success.
|
||||
func (c Config) CreateAlias(localPart, recipient string) error {
|
||||
if !c.Configured() {
|
||||
return fmt.Errorf("forwardemail not configured")
|
||||
}
|
||||
form := url.Values{
|
||||
"name": {localPart},
|
||||
"recipients": {recipient},
|
||||
"is_enabled": {"true"},
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
endpoint := apiBase + "/domains/" + url.PathEscape(c.Domain) + "/aliases"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// forwardemail uses HTTP basic auth with the API key as the username and an
|
||||
// empty password.
|
||||
req.SetBasicAuth(c.APIKey, "")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
// Re-running for an existing member is normal — don't treat it as an error.
|
||||
if strings.Contains(strings.ToLower(string(body)), "already exists") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("forwardemail create alias: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
27
internal/forwardemail/forwardemail_test.go
Normal file
27
internal/forwardemail/forwardemail_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package forwardemail
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConfiguredAndAddress(t *testing.T) {
|
||||
var empty Config
|
||||
if empty.Configured() {
|
||||
t.Fatal("empty config must not be Configured")
|
||||
}
|
||||
if (Config{APIKey: "k"}).Configured() {
|
||||
t.Fatal("API key without domain must not be Configured")
|
||||
}
|
||||
c := Config{APIKey: "k", Domain: "bbs.profullstack.com", Webmail: "https://webmail.example"}
|
||||
if !c.Configured() {
|
||||
t.Fatal("API key + domain should be Configured")
|
||||
}
|
||||
if got := c.Address("alice"); got != "alice@bbs.profullstack.com" {
|
||||
t.Fatalf("Address = %q", got)
|
||||
}
|
||||
if c.WebmailURL() != "https://webmail.example" {
|
||||
t.Fatalf("WebmailURL = %q", c.WebmailURL())
|
||||
}
|
||||
// Creating an alias without config is a clean error, not a panic.
|
||||
if err := empty.CreateAlias("alice", "alice@x.com"); err == nil {
|
||||
t.Fatal("CreateAlias on unconfigured must error")
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
|
@ -29,6 +30,114 @@ const PodPriceLabel = "$1/mo"
|
|||
// PodTerm is how much access one payment buys.
|
||||
const PodTerm = 31 * 24 * time.Hour
|
||||
|
||||
// PremiumPriceLabel is the human-readable price for the one-time lifetime
|
||||
// membership offered at join@.
|
||||
const PremiumPriceLabel = "$10 (lifetime)"
|
||||
|
||||
// premium charge defaults — all overridable via env so the CoinPay surface can
|
||||
// change without a rebuild (mirrors the pod templates above).
|
||||
func PremiumAmount() string { return envOr("AGENTBBS_PREMIUM_AMOUNT", "10") }
|
||||
func PremiumCurrency() string { return envOr("AGENTBBS_PREMIUM_CURRENCY", "USD") }
|
||||
func PremiumBlockchain() string { return envOr("AGENTBBS_PREMIUM_BLOCKCHAIN", "eth") }
|
||||
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Charge is a created CoinPay payment a user must fund: a unique deposit
|
||||
// address plus the crypto amount (and the fiat amount it settles).
|
||||
type Charge struct {
|
||||
Address string `json:"payment_address"`
|
||||
CryptoAmount string `json:"crypto_amount"`
|
||||
Currency string `json:"crypto_currency"`
|
||||
FiatAmount string `json:"amount"`
|
||||
FiatCurrency string `json:"currency"`
|
||||
ID string `json:"id"`
|
||||
QR string `json:"qr_code"`
|
||||
}
|
||||
|
||||
// PremiumReference derives the stable CoinPay memo for a user's lifetime
|
||||
// membership from their key fingerprint.
|
||||
func PremiumReference(pubkeyFP string) string { return Reference("premium", pubkeyFP) }
|
||||
|
||||
// CreatePremiumCharge shells out to the CoinPay CLI to mint a payment address
|
||||
// for the $10 lifetime membership and parses the JSON it prints. created is
|
||||
// false when no create command is configured or the CLI is unavailable, so the
|
||||
// caller can fall back to PremiumPayCommand. The reference is passed as the
|
||||
// payment metadata/memo so the eventual settlement reconciles to the account.
|
||||
//
|
||||
// AGENTBBS_COINPAY_PREMIUM_CREATE_CMD
|
||||
// default: coinpay payment create --amount 10 --currency USD --blockchain eth --json --metadata %s
|
||||
func CreatePremiumCharge(ref string) (Charge, bool, error) {
|
||||
tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_CREATE_CMD")
|
||||
if tmpl == "" {
|
||||
tmpl = "coinpay payment create --amount " + PremiumAmount() +
|
||||
" --currency " + PremiumCurrency() +
|
||||
" --blockchain " + PremiumBlockchain() + " --json --metadata %s"
|
||||
}
|
||||
line := tmpl
|
||||
if strings.Contains(tmpl, "%s") {
|
||||
line = fmt.Sprintf(tmpl, ref)
|
||||
} else {
|
||||
line = tmpl + " " + ref
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) == 0 {
|
||||
return Charge{}, false, nil
|
||||
}
|
||||
if _, err := exec.LookPath(parts[0]); err != nil {
|
||||
return Charge{}, false, nil // CLI not installed — caller falls back
|
||||
}
|
||||
out, err := exec.Command(parts[0], parts[1:]...).Output()
|
||||
if err != nil {
|
||||
return Charge{}, false, err
|
||||
}
|
||||
var c Charge
|
||||
if err := json.Unmarshal(out, &c); err != nil {
|
||||
// Some CLIs wrap the payment under a top-level key, e.g. {"payment":{…}}.
|
||||
var wrap struct {
|
||||
Payment Charge `json:"payment"`
|
||||
}
|
||||
if json.Unmarshal(out, &wrap) == nil && wrap.Payment.Address != "" {
|
||||
c = wrap.Payment
|
||||
} else {
|
||||
return Charge{}, false, err
|
||||
}
|
||||
}
|
||||
if c.Address == "" {
|
||||
return Charge{}, false, nil
|
||||
}
|
||||
return c, true, nil
|
||||
}
|
||||
|
||||
// PremiumPayCommand is the manual fallback shown when no charge could be minted
|
||||
// in-session: the command the user can run themselves to pay.
|
||||
//
|
||||
// AGENTBBS_COINPAY_PREMIUM_PAY_TMPL
|
||||
func PremiumPayCommand(ref string) string {
|
||||
tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_PAY_TMPL")
|
||||
if tmpl == "" {
|
||||
tmpl = "coinpay payment create --amount " + PremiumAmount() +
|
||||
" --currency " + PremiumCurrency() +
|
||||
" --blockchain " + PremiumBlockchain() + " --metadata %s"
|
||||
}
|
||||
if strings.Contains(tmpl, "%s") {
|
||||
return fmt.Sprintf(tmpl, ref)
|
||||
}
|
||||
return tmpl + " " + ref
|
||||
}
|
||||
|
||||
// VerifyPremium checks whether a premium charge has settled, via the CoinPay
|
||||
// status command. Like Verify, checked is false when unconfigured/unavailable.
|
||||
//
|
||||
// AGENTBBS_COINPAY_PREMIUM_STATUS_CMD e.g. "coinpay payment status %s" (exit 0 == paid)
|
||||
func VerifyPremium(payRef string) (paid bool, checked bool) {
|
||||
return runVerify(os.Getenv("AGENTBBS_COINPAY_PREMIUM_STATUS_CMD"), payRef)
|
||||
}
|
||||
|
||||
// Reference derives a stable, short payment reference for a user+plan from
|
||||
// the user's key fingerprint, so CoinPay memos can be reconciled to accounts.
|
||||
func Reference(plan, pubkeyFP string) string {
|
||||
|
|
@ -54,11 +163,17 @@ func PayCommand(ref string) string {
|
|||
// (paid, checked): checked is false when no verifier is configured or the
|
||||
// coinpay binary is unavailable, so callers can fall back to store state.
|
||||
func Verify(ref string) (paid bool, checked bool) {
|
||||
tmpl := os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD")
|
||||
return runVerify(os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD"), ref)
|
||||
}
|
||||
|
||||
// runVerify runs a "%s"-templated verify command and maps its exit status to
|
||||
// (paid, checked): checked is false when the template is empty or the binary is
|
||||
// absent, so callers fall back to store state.
|
||||
func runVerify(tmpl, ref string) (paid bool, checked bool) {
|
||||
if tmpl == "" {
|
||||
return false, false
|
||||
}
|
||||
var line string
|
||||
line := tmpl
|
||||
if strings.Contains(tmpl, "%s") {
|
||||
line = fmt.Sprintf(tmpl, ref)
|
||||
} else {
|
||||
|
|
@ -71,8 +186,7 @@ func Verify(ref string) (paid bool, checked bool) {
|
|||
if _, err := exec.LookPath(parts[0]); err != nil {
|
||||
return false, false
|
||||
}
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil {
|
||||
return false, true
|
||||
}
|
||||
return true, true
|
||||
|
|
|
|||
|
|
@ -18,22 +18,24 @@ type User struct {
|
|||
PubKeyFP string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Premium bool // paid the one-time lifetime membership
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// userCols is the column list (in struct order) for every user SELECT, kept in
|
||||
// sync with scanUser.
|
||||
const userCols = `id, name, kind, pubkey_fp, email, email_verified, created_at`
|
||||
const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, created_at`
|
||||
|
||||
// scanUser reads one user row selected with userCols.
|
||||
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
||||
var u User
|
||||
var verified int
|
||||
var verified, premium int
|
||||
var created string
|
||||
if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &created); err != nil {
|
||||
if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &created); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u.EmailVerified = verified != 0
|
||||
u.Premium = premium != 0
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, nil
|
||||
}
|
||||
|
|
@ -60,11 +62,21 @@ type Store interface {
|
|||
LastSeen(userID int64) (time.Time, bool, error)
|
||||
|
||||
// SetEmailVerification records the account's email and a fresh
|
||||
// confirmation token, marking it unverified until the token is used.
|
||||
// confirmation token (a link token or a short code), marking it unverified
|
||||
// until the token is consumed.
|
||||
SetEmailVerification(userID int64, email, token string) error
|
||||
// VerifyEmail consumes a confirmation token: on match it marks the
|
||||
// account verified, clears the token, and returns the account.
|
||||
VerifyEmail(token string) (User, bool, error)
|
||||
// ConfirmEmailCode is the interactive (join@) counterpart to VerifyEmail:
|
||||
// it matches the code against the one stored for THIS user (codes are
|
||||
// short and not globally unique), and on match marks the account verified
|
||||
// and clears the code. Returns ok=false on a wrong/empty code.
|
||||
ConfirmEmailCode(userID int64, code string) (User, bool, error)
|
||||
|
||||
// GrantPremium marks the account as a lifetime premium member (the $10
|
||||
// one-time membership), recording the CoinPay payment reference. Idempotent.
|
||||
GrantPremium(userID int64, paymentRef string) error
|
||||
|
||||
RecordSession(userID int64, username, remote, route string) (int64, error)
|
||||
EndSession(sessionID int64) error
|
||||
|
|
@ -140,6 +152,8 @@ func migrate(db *sql.DB) error {
|
|||
{"email", "email TEXT NOT NULL DEFAULT ''"},
|
||||
{"email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"},
|
||||
{"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"},
|
||||
{"premium", "premium INTEGER NOT NULL DEFAULT 0"},
|
||||
{"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -279,6 +293,30 @@ func (s *sqliteStore) VerifyEmail(token string) (User, bool, error) {
|
|||
return u, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) ConfirmEmailCode(userID int64, code string) (User, bool, error) {
|
||||
if code == "" {
|
||||
return User{}, false, nil
|
||||
}
|
||||
u, err := scanUser(s.db.QueryRow(
|
||||
`SELECT `+userCols+` FROM users WHERE id = ? AND verify_token = ?`, userID, code))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE users SET email_verified = 1, verify_token = '' WHERE id = ?`, u.ID); err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
u.EmailVerified = true
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GrantPremium(userID int64, paymentRef string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET premium = 1, premium_ref = ? WHERE id = ?`, paymentRef, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) RecordSession(userID int64, username, remote, route string) (int64, error) {
|
||||
var uid any
|
||||
if userID > 0 {
|
||||
|
|
|
|||
69
internal/store/store_premium_test.go
Normal file
69
internal/store/store_premium_test.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfirmEmailCode(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
u, err := st.EnsureUser("bob", "member", "SHA256:bbb")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
if err := st.SetEmailVerification(u.ID, "bob@example.com", "123456"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
|
||||
// Empty and wrong codes are clean misses.
|
||||
if _, ok, err := st.ConfirmEmailCode(u.ID, ""); ok || err != nil {
|
||||
t.Fatalf("empty code: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, ok, _ := st.ConfirmEmailCode(u.ID, "000000"); ok {
|
||||
t.Fatal("wrong code should not confirm")
|
||||
}
|
||||
// The right code belonging to another user must not confirm (codes are
|
||||
// scoped per-user since they are short and collide).
|
||||
other, _ := st.EnsureUser("carol", "member", "SHA256:ccc")
|
||||
if _, ok, _ := st.ConfirmEmailCode(other.ID, "123456"); ok {
|
||||
t.Fatal("code must be scoped to its own user")
|
||||
}
|
||||
|
||||
// Correct code for the right user verifies, and is single-use.
|
||||
vu, ok, err := st.ConfirmEmailCode(u.ID, "123456")
|
||||
if err != nil || !ok || !vu.EmailVerified {
|
||||
t.Fatalf("confirm: ok=%v err=%v verified=%v", ok, err, vu.EmailVerified)
|
||||
}
|
||||
if _, ok, _ := st.ConfirmEmailCode(u.ID, "123456"); ok {
|
||||
t.Fatal("code should be consumed after first use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantPremium(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
u, _ := st.EnsureUser("dave", "member", "SHA256:ddd")
|
||||
if u.Premium {
|
||||
t.Fatal("new user must not be premium")
|
||||
}
|
||||
if err := st.GrantPremium(u.ID, "abbs-premium-deadbeef"); err != nil {
|
||||
t.Fatalf("grant: %v", err)
|
||||
}
|
||||
got, _, _ := st.UserByFingerprint("SHA256:ddd")
|
||||
if !got.Premium {
|
||||
t.Fatalf("user should be premium after grant: %+v", got)
|
||||
}
|
||||
// Idempotent.
|
||||
if err := st.GrantPremium(u.ID, "abbs-premium-deadbeef"); err != nil {
|
||||
t.Fatalf("re-grant: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue