mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
bbs.profullstack.com launch kit: provisioner, custom domains, email verify, ascii-live
- setup.sh: idempotent one-shot droplet provisioner — agentbbs on :22 (admin OpenSSH moved to :2202), rootless podman, Caddy front end for https://bbs.profullstack.com with tilde-style /~user homepages - internal/sites + domain@ SSH route: self-service custom domains (ssh domain@host add example.com) backed by a symlink farm and an on-demand-TLS ask endpoint so Caddy only issues certs for mapped hosts - internal/mail + join@ email verification: optional email at signup, confirmation link served by a loopback /verify endpoint behind Caddy - internal/source + cmd/ascii-live: live video → terminal ASCII groundwork (docs/ascii-live.md) - store: additive sqlite migrations (email/verify columns, domains table) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9b0f465946
commit
f3f8e70996
14 changed files with 2005 additions and 27 deletions
|
|
@ -12,11 +12,30 @@ import (
|
|||
|
||||
// User is a persisted account (member or agent; guests are never stored).
|
||||
type User struct {
|
||||
ID int64
|
||||
Name string
|
||||
Kind string
|
||||
PubKeyFP string
|
||||
CreatedAt time.Time
|
||||
ID int64
|
||||
Name string
|
||||
Kind string
|
||||
PubKeyFP string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
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`
|
||||
|
||||
// scanUser reads one user row selected with userCols.
|
||||
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
|
||||
var u User
|
||||
var verified int
|
||||
var created string
|
||||
if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &created); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
u.EmailVerified = verified != 0
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Score is one leaderboard entry.
|
||||
|
|
@ -40,6 +59,13 @@ type Store interface {
|
|||
// LastSeen reports the start of the user's most recent session.
|
||||
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.
|
||||
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)
|
||||
|
||||
RecordSession(userID int64, username, remote, route string) (int64, error)
|
||||
EndSession(sessionID int64) error
|
||||
|
||||
|
|
@ -54,6 +80,15 @@ type Store interface {
|
|||
AddChat(userID int64, username, role, text string) error
|
||||
RecentChats(username string, n int) ([]ChatMessage, 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).
|
||||
MapDomain(domain, username string) error
|
||||
UnmapDomain(domain, username string) error
|
||||
DomainUser(domain string) (string, bool, error)
|
||||
DomainsForUser(username string) ([]string, error)
|
||||
AllDomains() ([]DomainMap, error)
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
|
|
@ -64,9 +99,21 @@ type ChatMessage struct {
|
|||
At time.Time
|
||||
}
|
||||
|
||||
// DomainMap binds a custom domain to a member's homepage (the public_html that
|
||||
// is also served at /~name). Used to serve, e.g., https://chovy.com from
|
||||
// users/chovy/public_html.
|
||||
type DomainMap struct {
|
||||
Domain string
|
||||
Username string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// ErrKeyMismatch means a username is already registered with another key.
|
||||
var ErrKeyMismatch = errors.New("username registered with a different key")
|
||||
|
||||
// ErrDomainTaken means a domain is already mapped to a different member.
|
||||
var ErrDomainTaken = errors.New("domain already mapped to another account")
|
||||
|
||||
type sqliteStore struct{ db *sql.DB }
|
||||
|
||||
// Open opens (and migrates) the SQLite store at path.
|
||||
|
|
@ -79,9 +126,54 @@ func Open(path string) (Store, error) {
|
|||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := migrate(db); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &sqliteStore{db: db}, nil
|
||||
}
|
||||
|
||||
// migrate applies additive schema changes that must not fail on existing
|
||||
// databases. New columns live here (not in schema) so there is one code path.
|
||||
func migrate(db *sql.DB) error {
|
||||
return ensureColumns(db, "users", [][2]string{
|
||||
{"email", "email TEXT NOT NULL DEFAULT ''"},
|
||||
{"email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"},
|
||||
{"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"},
|
||||
})
|
||||
}
|
||||
|
||||
// ensureColumns adds any missing {name, "name TYPE …"} columns to table.
|
||||
func ensureColumns(db *sql.DB, table string, cols [][2]string) error {
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid, notnull, pk int
|
||||
var name, ctype string
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
have[name] = true
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cols {
|
||||
if !have[c[0]] {
|
||||
if _, err := db.Exec(`ALTER TABLE ` + table + ` ADD COLUMN ` + c[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
|
|
@ -122,15 +214,18 @@ CREATE TABLE IF NOT EXISTS pod_subscriptions (
|
|||
payment_ref TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
domain TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_domains_user ON domains(username);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
var u User
|
||||
var created string
|
||||
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
|
||||
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
|
||||
u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name))
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
res, err := s.db.Exec(`INSERT INTO users (name, kind, pubkey_fp) VALUES (?,?,?)`, name, kind, fp)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
|
|
@ -143,7 +238,6 @@ func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
|||
if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp {
|
||||
return User{}, ErrKeyMismatch
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
|
|
@ -151,17 +245,37 @@ func (s *sqliteStore) UserByFingerprint(fp string) (User, bool, error) {
|
|||
if fp == "" {
|
||||
return User{}, false, nil
|
||||
}
|
||||
var u User
|
||||
var created string
|
||||
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE pubkey_fp = ?`, fp).
|
||||
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE pubkey_fp = ?`, fp))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) SetEmailVerification(userID int64, email, token string) error {
|
||||
_, err := s.db.Exec(`UPDATE users SET email = ?, verify_token = ?, email_verified = 0 WHERE id = ?`,
|
||||
email, token, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) VerifyEmail(token string) (User, bool, error) {
|
||||
if token == "" {
|
||||
return User{}, false, nil
|
||||
}
|
||||
u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE verify_token = ?`, token))
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -236,17 +350,13 @@ func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error
|
|||
}
|
||||
|
||||
func (s *sqliteStore) UserByName(name string) (User, bool, error) {
|
||||
var u User
|
||||
var created string
|
||||
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
|
||||
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
u, err := scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE name = ?`, name))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
|
|
@ -296,4 +406,73 @@ func (s *sqliteStore) RecentChats(username string, n int) ([]ChatMessage, error)
|
|||
return out, 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)
|
||||
switch {
|
||||
case err == nil:
|
||||
if owner != username {
|
||||
return ErrDomainTaken
|
||||
}
|
||||
return nil // already ours
|
||||
case err != sql.ErrNoRows:
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(`INSERT INTO domains (domain, username) VALUES (?,?)`, domain, username)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UnmapDomain(domain, username string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM domains WHERE domain = ? AND username = ?`, domain, username)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DomainUser(domain string) (string, bool, error) {
|
||||
var username string
|
||||
err := s.db.QueryRow(`SELECT username FROM domains WHERE domain = ?`, domain).Scan(&username)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return username, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) DomainsForUser(username string) ([]string, error) {
|
||||
rows, err := s.db.Query(`SELECT domain FROM domains WHERE username = ? ORDER BY domain`, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) AllDomains() ([]DomainMap, error) {
|
||||
rows, err := s.db.Query(`SELECT domain, username, created_at FROM domains ORDER BY domain`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []DomainMap
|
||||
for rows.Next() {
|
||||
var dm DomainMap
|
||||
var at string
|
||||
if err := rows.Scan(&dm.Domain, &dm.Username, &at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dm.At, _ = time.Parse(time.RFC3339, at)
|
||||
out = append(out, dm)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) Close() error { return s.db.Close() }
|
||||
|
|
|
|||
68
internal/store/store_email_test.go
Normal file
68
internal/store/store_email_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmailVerificationRoundtrip(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("alice", "member", "SHA256:aaa")
|
||||
if err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
if u.EmailVerified {
|
||||
t.Fatal("new user should be unverified")
|
||||
}
|
||||
|
||||
if err := st.SetEmailVerification(u.ID, "alice@example.com", "tok123"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
// Email is stored but not yet verified.
|
||||
got, _, err := st.UserByFingerprint("SHA256:aaa")
|
||||
if err != nil {
|
||||
t.Fatalf("byfp: %v", err)
|
||||
}
|
||||
if got.Email != "alice@example.com" || got.EmailVerified {
|
||||
t.Fatalf("pre-verify state wrong: %+v", got)
|
||||
}
|
||||
|
||||
// Wrong token does nothing.
|
||||
if _, ok, _ := st.VerifyEmail("nope"); ok {
|
||||
t.Fatal("bad token should not verify")
|
||||
}
|
||||
|
||||
// Correct token verifies and returns the account.
|
||||
vu, ok, err := st.VerifyEmail("tok123")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("verify: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if vu.Name != "alice" || !vu.EmailVerified {
|
||||
t.Fatalf("verify returned wrong user: %+v", vu)
|
||||
}
|
||||
|
||||
// Token is single-use (cleared after verification).
|
||||
if _, ok, _ := st.VerifyEmail("tok123"); ok {
|
||||
t.Fatal("token should be consumed after first use")
|
||||
}
|
||||
final, _, _ := st.UserByName("alice")
|
||||
if !final.EmailVerified {
|
||||
t.Fatal("user should remain verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailEmptyToken(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
if _, ok, err := st.VerifyEmail(""); ok || err != nil {
|
||||
t.Fatalf("empty token must be a clean miss: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue