rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "embed"
6 "errors"
7 "strconv"
8 "strings"
9 "time"
10
11 "gorm.io/gorm"
12
13 "amadan.net/rastrillo/rastrillo/auth"
14 "amadan.net/rastrillo/rastrillo/migrate"
15 "amadan.net/rastrillo/rastrillo/sessions"
16
17 "amadan.net/rastrillo/abono"
18)
19
20// User is a person, known by the address they sign in with. A row is
21// made the first time an address signs in by its emailed link, or pays
22// (holder, below), whichever comes first. Either way the only way into
23// it is a link sent to that address.
24type User struct {
25 ID int64
26 Email string `gorm:"uniqueIndex"`
27 CreatedAt time.Time
28}
29
30// Models is every model the schema generator manages.
31var Models = []any{&User{}}
32
33//go:embed migrations/*.sql
34var migrationFS embed.FS
35
36// Schema is the example's own migrations. BootSchema is everything
37// applied at boot: sessions, auth (whose backfill reads sessions),
38// abono's tables, then the example's.
39var (
40 Schema = migrate.MustFromFS(migrationFS, "example")
41 BootSchema = migrate.Merge(sessions.Schema, auth.Schema, abono.Schema, Schema)
42)
43
44func subjectOf(id int64) string { return strconv.FormatInt(id, 10) }
45
46func normalEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
47
48// ensureUser is the user with this address, made if there is none, as
49// a subject. g may be a transaction.
50func ensureUser(ctx context.Context, g *gorm.DB, email string) (string, error) {
51 var u User
52 err := g.WithContext(ctx).Where("email = ?", email).Take(&u).Error
53 if errors.Is(err, gorm.ErrRecordNotFound) {
54 u = User{Email: email, CreatedAt: time.Now().UTC()}
55 err = g.WithContext(ctx).Create(&u).Error
56 }
57 if err != nil {
58 return "", err
59 }
60 return subjectOf(u.ID), nil
61}
62
63// holder is abono's Holder: paying is a way in, so an address abono has
64// no subject for gets a user. Nobody is signed in by it; whoever reads
65// that address's mail can sign in later and finds the membership there.
66// It runs inside abono's transaction, so it uses tx.
67func holder(ctx context.Context, tx *gorm.DB, email, _ string) (string, error) {
68 email = normalEmail(email)
69 if email == "" {
70 return "", nil
71 }
72 return ensureUser(ctx, tx, email)
73}
74
75// subjectForAddress is auth's SubjectFor: a verified address signs in
76// as its user, made on first sign-in.
77func subjectForAddress(g *gorm.DB) func(string) (string, error) {
78 return func(address string) (string, error) {
79 return ensureUser(context.Background(), g, normalEmail(address))
80 }
81}
82
83// userByEmail is the subject of the person with this address, for the
84// owner's free-membership form. It makes nobody.
85func userByEmail(g *gorm.DB) func(context.Context, string) (string, bool) {
86 return func(ctx context.Context, email string) (string, bool) {
87 var u User
88 if err := g.WithContext(ctx).Where("email = ?", normalEmail(email)).Take(&u).Error; err != nil {
89 return "", false
90 }
91 return subjectOf(u.ID), true
92 }
93}
94
95// emailOf is the address of subject, "" when there is none. It is the
96// owner's pages' Label too.
97func emailOf(g *gorm.DB) func(context.Context, string) string {
98 return func(ctx context.Context, subject string) string {
99 id, err := strconv.ParseInt(subject, 10, 64)
100 if err != nil {
101 return ""
102 }
103 var u User
104 if err := g.WithContext(ctx).Where("id = ?", id).Take(&u).Error; err != nil {
105 return ""
106 }
107 return u.Email
108 }
109}
110