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:
Anthony Ettinger 2026-06-11 15:53:19 +00:00
parent 9b0f465946
commit f3f8e70996
14 changed files with 2005 additions and 27 deletions

View file

@ -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, &notnull, &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() }