package main import ( "context" "embed" "errors" "strconv" "strings" "time" "gorm.io/gorm" "amadan.net/rastrillo/rastrillo/auth" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/rastrillo/rastrillo/sessions" "amadan.net/rastrillo/abono" ) // User is a person, known by the address they sign in with. A row is // made the first time an address signs in by its emailed link, or pays // (holder, below), whichever comes first. Either way the only way into // it is a link sent to that address. type User struct { ID int64 Email string `gorm:"uniqueIndex"` CreatedAt time.Time } // Models is every model the schema generator manages. var Models = []any{&User{}} //go:embed migrations/*.sql var migrationFS embed.FS // Schema is the example's own migrations. BootSchema is everything // applied at boot: sessions, auth (whose backfill reads sessions), // abono's tables, then the example's. var ( Schema = migrate.MustFromFS(migrationFS, "example") BootSchema = migrate.Merge(sessions.Schema, auth.Schema, abono.Schema, Schema) ) func subjectOf(id int64) string { return strconv.FormatInt(id, 10) } func normalEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) } // ensureUser is the user with this address, made if there is none, as // a subject. g may be a transaction. func ensureUser(ctx context.Context, g *gorm.DB, email string) (string, error) { var u User err := g.WithContext(ctx).Where("email = ?", email).Take(&u).Error if errors.Is(err, gorm.ErrRecordNotFound) { u = User{Email: email, CreatedAt: time.Now().UTC()} err = g.WithContext(ctx).Create(&u).Error } if err != nil { return "", err } return subjectOf(u.ID), nil } // holder is abono's Holder: paying is a way in, so an address abono has // no subject for gets a user. Nobody is signed in by it; whoever reads // that address's mail can sign in later and finds the membership there. // It runs inside abono's transaction, so it uses tx. func holder(ctx context.Context, tx *gorm.DB, email, _ string) (string, error) { email = normalEmail(email) if email == "" { return "", nil } return ensureUser(ctx, tx, email) } // subjectForAddress is auth's SubjectFor: a verified address signs in // as its user, made on first sign-in. func subjectForAddress(g *gorm.DB) func(string) (string, error) { return func(address string) (string, error) { return ensureUser(context.Background(), g, normalEmail(address)) } } // userByEmail is the subject of the person with this address, for the // owner's free-membership form. It makes nobody. func userByEmail(g *gorm.DB) func(context.Context, string) (string, bool) { return func(ctx context.Context, email string) (string, bool) { var u User if err := g.WithContext(ctx).Where("email = ?", normalEmail(email)).Take(&u).Error; err != nil { return "", false } return subjectOf(u.ID), true } } // emailOf is the address of subject, "" when there is none. It is the // owner's pages' Label too. func emailOf(g *gorm.DB) func(context.Context, string) string { return func(ctx context.Context, subject string) string { id, err := strconv.ParseInt(subject, 10, 64) if err != nil { return "" } var u User if err := g.WithContext(ctx).Where("id = ?", id).Take(&u).Error; err != nil { return "" } return u.Email } }