1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
|
package controlplane
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
"aigw/internal/mailer"
"github.com/jackc/pgx/v5"
)
const (
actionVerifyEmail = "verify_email"
actionPasswordReset = "password_reset"
actionInvite = "invite"
)
func (s *Store) issueActionToken(ctx context.Context, tx pgx.Tx, userID, email, displayName, purpose, publicURL, remoteIP string, ttl time.Duration) (string, error) {
token, tokenHash, err := randomCredential("act-aigw-")
if err != nil {
return "", err
}
if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now()
WHERE user_id=$1 AND purpose=$2 AND consumed_at IS NULL`, userID, purpose); err != nil {
return "", fmt.Errorf("expire previous account action: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO console_action_tokens
(user_id,purpose,token_hash,expires_at,requested_ip) VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet)`,
userID, purpose, tokenHash, time.Now().UTC().Add(ttl), remoteIP); err != nil {
return "", fmt.Errorf("create account action: %w", err)
}
link, err := accountActionURL(publicURL, purpose, token)
if err != nil {
return "", err
}
subject, body := actionMail(displayName, purpose, link, ttl)
ciphertext, err := s.cipher.Encrypt(body)
if err != nil {
return "", fmt.Errorf("encrypt account email: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO console_mail_outbox
(recipient,template,subject,body_ciphertext) VALUES ($1,$2,$3,$4)`, email, purpose, subject, ciphertext); err != nil {
return "", fmt.Errorf("queue account email: %w", err)
}
return token, nil
}
func accountActionURL(publicURL, purpose, token string) (string, error) {
parsed, err := url.Parse(publicURL)
if err != nil || parsed.Host == "" {
return "", errors.New("console public URL is invalid")
}
query := parsed.Query()
switch purpose {
case actionVerifyEmail:
query.Set("action", "verify-email")
case actionPasswordReset:
query.Set("action", "reset-password")
case actionInvite:
query.Set("action", "accept-invite")
default:
return "", errors.New("unsupported account action")
}
query.Set("token", token)
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func actionMail(displayName, purpose, link string, ttl time.Duration) (string, string) {
name := strings.TrimSpace(displayName)
if name == "" {
name = "there"
}
minutes := int(ttl.Minutes())
expires := fmt.Sprintf("%d minutes", minutes)
if minutes%1440 == 0 {
expires = durationLabel(minutes/1440, "day")
} else if minutes%60 == 0 {
expires = durationLabel(minutes/60, "hour")
}
switch purpose {
case actionVerifyEmail:
return "Verify your AIGW email", fmt.Sprintf("Hi %s,\n\nVerify your email to activate your AIGW workspace:\n\n%s\n\nThis link expires in %s and can be used once. If you did not create this account, ignore this email.\n", name, link, expires)
case actionPasswordReset:
return "Reset your AIGW password", fmt.Sprintf("Hi %s,\n\nReset your AIGW password:\n\n%s\n\nThis link expires in %s and can be used once. If you did not request this, your password has not changed.\n", name, link, expires)
default:
return "You have been invited to AIGW", fmt.Sprintf("Hi %s,\n\nAccept your AIGW invitation and choose a password:\n\n%s\n\nThis link expires in %s and can be used once.\n", name, link, expires)
}
}
func durationLabel(value int, unit string) string {
if value != 1 {
unit += "s"
}
return fmt.Sprintf("%d %s", value, unit)
}
func (s *Store) ClaimMail(ctx context.Context) (mailer.Message, bool, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return mailer.Message{}, false, err
}
defer tx.Rollback(ctx)
var message mailer.Message
var ciphertext []byte
err = tx.QueryRow(ctx, `WITH candidate AS (
SELECT id FROM console_mail_outbox
WHERE ((status IN ('pending','failed') AND available_at <= now())
OR (status='sending' AND claimed_at < now()-interval '5 minutes'))
ORDER BY available_at,created_at FOR UPDATE SKIP LOCKED LIMIT 1
) UPDATE console_mail_outbox o SET status='sending',claimed_at=now(),attempts=attempts+1,last_error=''
FROM candidate WHERE o.id=candidate.id
RETURNING o.id::text,o.recipient,o.subject,o.body_ciphertext`,
).Scan(&message.ID, &message.Recipient, &message.Subject, &ciphertext)
if errors.Is(err, pgx.ErrNoRows) {
return mailer.Message{}, false, nil
}
if err != nil {
return mailer.Message{}, false, fmt.Errorf("claim mail outbox item: %w", err)
}
message.Body, err = s.cipher.Decrypt(ciphertext)
if err != nil {
return mailer.Message{}, false, fmt.Errorf("decrypt mail outbox item: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return mailer.Message{}, false, err
}
return message, true, nil
}
func (s *Store) MarkMailSent(ctx context.Context, id string) error {
_, err := s.db.Exec(ctx, `UPDATE console_mail_outbox SET status='sent',sent_at=now(),claimed_at=NULL,last_error=''
WHERE id=$1 AND status='sending'`, id)
return err
}
func (s *Store) MarkMailFailed(ctx context.Context, id string, deliveryErr error) error {
message := "delivery failed"
if deliveryErr != nil {
message = deliveryErr.Error()
}
if len(message) > 1000 {
message = message[:1000]
}
_, err := s.db.Exec(ctx, `UPDATE console_mail_outbox SET status='failed',claimed_at=NULL,last_error=$2,
available_at=now()+make_interval(secs => LEAST(300, 5 * attempts)) WHERE id=$1 AND status='sending'`, id, message)
return err
}
|