From a2649b39b672994fefe77e73f9b767ddff8289b9 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Wed, 26 Aug 2026 16:05:36 +0300 Subject: [PATCH] deliberi/db: track token, expiry, and last-sent for inflight signups Signed-off-by: Anirudh Oppiliappan --- deliberi/db/db.go | 41 +++++++++++++- deliberi/db/signup.go | 45 ++++++++++++--- deliberi/db/signup_test.go | 111 +++++++++++++++++++++++++++++++++++++ deliberi/models/signup.go | 11 ++-- 4 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 deliberi/db/signup_test.go diff --git a/deliberi/db/db.go b/deliberi/db/db.go index a9a2f6554..6478bd4a6 100644 --- a/deliberi/db/db.go +++ b/deliberi/db/db.go @@ -70,8 +70,13 @@ create table if not exists signups_inflight ( id integer primary key autoincrement, email text not null unique, invite_code text not null, - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + verification_token text not null default '', + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + expires_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+24 hours')), + last_sent text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); +-- the verification_token index is built by the signup-token-index migration, +-- never here: a pre-token database must gain the column before the index. -- one materialized row per (recipient, source record). deliberi owns these; -- read/emailed are inline. unique(recipient_did, at_uri) dedupes fan-out. @@ -171,6 +176,40 @@ func runMigrations(conn *sql.Conn, logger *slog.Logger) error { }); err != nil { return err } + if err := orm.RunMigration(conn, logger, "signup-expiry", func(tx *sql.Tx) error { + if _, err := tx.Exec(`alter table signups_inflight add column expires_at text not null default ''`); err != nil && !isColumnExistsErr(err) { + return err + } + // rows predating the column get a 24h lifetime from migration time; new + // rows already default to +24h at insert (see the schema) + _, err := tx.Exec(`update signups_inflight set expires_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+24 hours') where expires_at = ''`) + return err + }); err != nil { + return err + } + if err := orm.RunMigration(conn, logger, "signup-token-index", func(tx *sql.Tx) error { + // '' rows from before the column existed get unique tokens so the index holds + if _, err := tx.Exec(`alter table signups_inflight add column verification_token text not null default ''`); err != nil && !isColumnExistsErr(err) { + return err + } + if _, err := tx.Exec(`update signups_inflight set verification_token = lower(hex(randomblob(32))) where verification_token = ''`); err != nil { + return err + } + _, err := tx.Exec(`create unique index if not exists signups_inflight_verification_token_unique on signups_inflight (verification_token)`) + return err + }); err != nil { + return err + } + if err := orm.RunMigration(conn, logger, "signup-resend-cooldown", func(tx *sql.Tx) error { + // "-1 day": an upgraded row must never block a first send + if _, err := tx.Exec(`alter table signups_inflight add column last_sent text not null default ''`); err != nil && !isColumnExistsErr(err) { + return err + } + _, err := tx.Exec(`update signups_inflight set last_sent = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day') where last_sent = ''`) + return err + }); err != nil { + return err + } return nil } diff --git a/deliberi/db/signup.go b/deliberi/db/signup.go index f4fce6445..8d0ccf024 100644 --- a/deliberi/db/signup.go +++ b/deliberi/db/signup.go @@ -5,8 +5,42 @@ import ( ) func AddInflightSignup(e Execer, signup models.InflightSignup) error { - query := `insert or replace into signups_inflight (email, invite_code) values (?, ?)` - _, err := e.Exec(query, signup.Email, signup.InviteCode) + // strftime because migrated DBs keep constant '' column defaults + query := `insert or replace into signups_inflight (email, invite_code, verification_token, expires_at, last_sent) + values (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+24 hours'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))` + _, err := e.Exec(query, signup.Email, signup.InviteCode, signup.VerificationToken) + return err +} + +func GetInflightByToken(e Execer, token string) (models.InflightSignup, error) { + query := `select email, invite_code, verification_token, last_sent + from signups_inflight + where verification_token = ? and expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')` + var s models.InflightSignup + err := e.QueryRow(query, token).Scan(&s.Email, &s.InviteCode, &s.VerificationToken, &s.LastSent) + return s, err +} + +// GetInflightByEmail returns the live (unexpired) row; sql.ErrNoRows when none. +func GetInflightByEmail(e Execer, email string) (models.InflightSignup, error) { + query := `select email, invite_code, verification_token, last_sent + from signups_inflight + where email = ? and expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')` + var s models.InflightSignup + err := e.QueryRow(query, email).Scan(&s.Email, &s.InviteCode, &s.VerificationToken, &s.LastSent) + return s, err +} + +func UpdateInflightLastSent(e Execer, email string) error { + _, err := e.Exec(`update signups_inflight set last_sent = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') where email = ?`, email) + return err +} + +// DeleteExpiredInflightSignups frees the PDS invite codes of stale signups; +// handlers call it on a lookup miss. +func DeleteExpiredInflightSignups(e Execer) error { + _, err := e.Exec(`delete from signups_inflight + where expires_at <> '' and expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`) return err } @@ -15,10 +49,3 @@ func DeleteInflightSignup(e Execer, email string) error { _, err := e.Exec(query, email) return err } - -func GetEmailForCode(e Execer, inviteCode string) (string, error) { - query := `select email from signups_inflight where invite_code = ?` - var email string - err := e.QueryRow(query, inviteCode).Scan(&email) - return email, err -} diff --git a/deliberi/db/signup_test.go b/deliberi/db/signup_test.go new file mode 100644 index 000000000..d203f829b --- /dev/null +++ b/deliberi/db/signup_test.go @@ -0,0 +1,111 @@ +package db + +import ( + "context" + "database/sql" + "path/filepath" + "strings" + "testing" + + "tangled.org/core/deliberi/models" +) + +func TestMakeUpgradesLegacySignupsInflight(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + + legacy, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatalf("sql.Open: %v", err) + } + if _, err := legacy.Exec(`create table signups_inflight ( + id integer primary key autoincrement, + email text not null unique, + invite_code text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + )`); err != nil { + t.Fatalf("create legacy table: %v", err) + } + if _, err := legacy.Exec(`insert into signups_inflight (email, invite_code) values + ('a@example.com', 'code-a'), + ('b@example.com', 'code-b')`); err != nil { + t.Fatalf("seed legacy rows: %v", err) + } + if err := legacy.Close(); err != nil { + t.Fatalf("close legacy db: %v", err) + } + + d, err := Make(context.Background(), path) + if err != nil { + t.Fatalf("Make on legacy db: %v", err) + } + defer d.Close() + + rows, err := d.Query(`select email, verification_token, expires_at, last_sent from signups_inflight order by email`) + if err != nil { + t.Fatalf("query upgraded rows: %v", err) + } + defer rows.Close() + + type row struct { + email, token, expires, lastSent string + } + var got []row + for rows.Next() { + var r row + if err := rows.Scan(&r.email, &r.token, &r.expires, &r.lastSent); err != nil { + t.Fatalf("scan: %v", err) + } + got = append(got, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + if len(got) != 2 { + t.Fatalf("row count = %d, want 2", len(got)) + } + tokens := map[string]bool{} + for _, r := range got { + if len(r.token) != 64 { + t.Errorf("token %q for %s not 64 chars", r.token, r.email) + } + if r.expires == "" { + t.Errorf("expires_at empty for %s", r.email) + } + if r.lastSent == "" { + t.Errorf("last_sent empty for %s", r.email) + } + if tokens[r.token] { + t.Errorf("duplicate token %q", r.token) + } + tokens[r.token] = true + } + + var indexName string + err = d.QueryRow(`select name from sqlite_master + where type = 'index' and name = 'signups_inflight_verification_token_unique'`).Scan(&indexName) + if err != nil { + t.Fatalf("unique index missing after upgrade: %v", err) + } + if !strings.Contains(indexName, "verification_token_unique") { + t.Fatalf("unexpected index name %q", indexName) + } +} + +func TestMakeFreshSignupsInflight(t *testing.T) { + d, err := Make(context.Background(), filepath.Join(t.TempDir(), "fresh.db")) + if err != nil { + t.Fatalf("Make: %v", err) + } + defer d.Close() + + if err := AddInflightSignup(d, models.InflightSignup{Email: "c@example.com", InviteCode: "code-c", VerificationToken: strings.Repeat("ab", 32)}); err != nil { + t.Fatalf("AddInflightSignup: %v", err) + } + row, err := GetInflightByEmail(d, "c@example.com") + if err != nil { + t.Fatalf("GetInflightByEmail: %v", err) + } + if row.LastSent == "" { + t.Errorf("last_sent not set on insert") + } +} diff --git a/deliberi/models/signup.go b/deliberi/models/signup.go index 6d8be0f56..8bc038b35 100644 --- a/deliberi/models/signup.go +++ b/deliberi/models/signup.go @@ -3,8 +3,11 @@ package models import "time" type InflightSignup struct { - Id int64 - Email string - InviteCode string - Created time.Time + Id int64 + Email string + InviteCode string + VerificationToken string + Created time.Time + // ISO-8601 (UTC) last sent; gates the email throttle. + LastSent string } -- 2.51.2