1package server
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "time"
9
10 "github.com/Azure/go-autorest/autorest/to"
11 "github.com/bluesky-social/indigo/api/atproto"
12 "github.com/bluesky-social/indigo/atproto/crypto"
13 "github.com/bluesky-social/indigo/atproto/syntax"
14 "github.com/bluesky-social/indigo/events"
15 "github.com/bluesky-social/indigo/repo"
16 "github.com/bluesky-social/indigo/util"
17 "github.com/haileyok/cocoon/blockstore"
18 "github.com/haileyok/cocoon/internal/helpers"
19 "github.com/haileyok/cocoon/models"
20 "github.com/labstack/echo/v4"
21 "golang.org/x/crypto/bcrypt"
22 "gorm.io/gorm"
23)
24
25type ComAtprotoServerCreateAccountRequest struct {
26 Email string `json:"email" validate:"required,email"`
27 Handle string `json:"handle" validate:"required,atproto-handle"`
28 Did *string `json:"did" validate:"atproto-did"`
29 Password string `json:"password" validate:"required"`
30 InviteCode string `json:"inviteCode" validate:"required"`
31}
32
33type ComAtprotoServerCreateAccountResponse struct {
34 AccessJwt string `json:"accessJwt"`
35 RefreshJwt string `json:"refreshJwt"`
36 Handle string `json:"handle"`
37 Did string `json:"did"`
38}
39
40func (s *Server) handleCreateAccount(e echo.Context) error {
41 var request ComAtprotoServerCreateAccountRequest
42
43 var signupDid string
44 customDidHeader := e.Request().Header.Get("authorization")
45 if customDidHeader != "" {
46 pts := strings.Split(customDidHeader, " ")
47 if len(pts) != 2 {
48 return helpers.InputError(e, to.StringPtr("InvalidDid"))
49 }
50
51 _, err := syntax.ParseDID(pts[1])
52 if err != nil {
53 return helpers.InputError(e, to.StringPtr("InvalidDid"))
54 }
55
56 signupDid = pts[1]
57 }
58
59 if err := e.Bind(&request); err != nil {
60 s.logger.Error("error receiving request", "endpoint", "com.atproto.server.createAccount", "error", err)
61 return helpers.ServerError(e, nil)
62 }
63
64 request.Handle = strings.ToLower(request.Handle)
65
66 if err := e.Validate(request); err != nil {
67 s.logger.Error("error validating request", "endpoint", "com.atproto.server.createAccount", "error", err)
68
69 var verr ValidationError
70 if errors.As(err, &verr) {
71 if verr.Field == "Email" {
72 // TODO: what is this supposed to be? `InvalidEmail` isn't listed in doc
73 return helpers.InputError(e, to.StringPtr("InvalidEmail"))
74 }
75
76 if verr.Field == "Handle" {
77 return helpers.InputError(e, to.StringPtr("InvalidHandle"))
78 }
79
80 if verr.Field == "Password" {
81 return helpers.InputError(e, to.StringPtr("InvalidPassword"))
82 }
83
84 if verr.Field == "InviteCode" {
85 return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
86 }
87 }
88 }
89
90 // see if the handle is already taken
91 _, err := s.getActorByHandle(request.Handle)
92 if err != nil && err != gorm.ErrRecordNotFound {
93 s.logger.Error("error looking up handle in db", "endpoint", "com.atproto.server.createAccount", "error", err)
94 return helpers.ServerError(e, nil)
95 }
96 if err == nil {
97 return helpers.InputError(e, to.StringPtr("HandleNotAvailable"))
98 }
99
100 if did, err := s.passport.ResolveHandle(e.Request().Context(), request.Handle); err == nil && did != "" {
101 return helpers.InputError(e, to.StringPtr("HandleNotAvailable"))
102 }
103
104 var ic models.InviteCode
105 if err := s.db.Raw("SELECT * FROM invite_codes WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil {
106 if err == gorm.ErrRecordNotFound {
107 return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
108 }
109 s.logger.Error("error getting invite code from db", "error", err)
110 return helpers.ServerError(e, nil)
111 }
112
113 if ic.RemainingUseCount < 1 {
114 return helpers.InputError(e, to.StringPtr("InvalidInviteCode"))
115 }
116
117 // see if the email is already taken
118 _, err = s.getRepoByEmail(request.Email)
119 if err != nil && err != gorm.ErrRecordNotFound {
120 s.logger.Error("error looking up email in db", "endpoint", "com.atproto.server.createAccount", "error", err)
121 return helpers.ServerError(e, nil)
122 }
123 if err == nil {
124 return helpers.InputError(e, to.StringPtr("EmailNotAvailable"))
125 }
126
127 // TODO: unsupported domains
128
129 k, err := crypto.GeneratePrivateKeyK256()
130 if err != nil {
131 s.logger.Error("error creating signing key", "endpoint", "com.atproto.server.createAccount", "error", err)
132 return helpers.ServerError(e, nil)
133 }
134
135 if signupDid == "" {
136 did, op, err := s.plcClient.CreateDID(k, "", request.Handle)
137 if err != nil {
138 s.logger.Error("error creating operation", "endpoint", "com.atproto.server.createAccount", "error", err)
139 return helpers.ServerError(e, nil)
140 }
141
142 if err := s.plcClient.SendOperation(e.Request().Context(), did, op); err != nil {
143 s.logger.Error("error sending plc op", "endpoint", "com.atproto.server.createAccount", "error", err)
144 return helpers.ServerError(e, nil)
145 }
146 signupDid = did
147 }
148
149 hashed, err := bcrypt.GenerateFromPassword([]byte(request.Password), 10)
150 if err != nil {
151 s.logger.Error("error hashing password", "error", err)
152 return helpers.ServerError(e, nil)
153 }
154
155 urepo := models.Repo{
156 Did: signupDid,
157 CreatedAt: time.Now(),
158 Email: request.Email,
159 EmailVerificationCode: to.StringPtr(fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))),
160 Password: string(hashed),
161 SigningKey: k.Bytes(),
162 }
163
164 actor := models.Actor{
165 Did: signupDid,
166 Handle: request.Handle,
167 }
168
169 if err := s.db.Create(&urepo).Error; err != nil {
170 s.logger.Error("error inserting new repo", "error", err)
171 return helpers.ServerError(e, nil)
172 }
173
174 if err := s.db.Create(&actor).Error; err != nil {
175 s.logger.Error("error inserting new actor", "error", err)
176 return helpers.ServerError(e, nil)
177 }
178
179 if customDidHeader == "" {
180 bs := blockstore.New(signupDid, s.db)
181 r := repo.NewRepo(context.TODO(), signupDid, bs)
182
183 root, rev, err := r.Commit(context.TODO(), urepo.SignFor)
184 if err != nil {
185 s.logger.Error("error committing", "error", err)
186 return helpers.ServerError(e, nil)
187 }
188
189 if err := bs.UpdateRepo(context.TODO(), root, rev); err != nil {
190 s.logger.Error("error updating repo after commit", "error", err)
191 return helpers.ServerError(e, nil)
192 }
193
194 s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
195 RepoHandle: &atproto.SyncSubscribeRepos_Handle{
196 Did: urepo.Did,
197 Handle: request.Handle,
198 Seq: time.Now().UnixMicro(), // TODO: no
199 Time: time.Now().Format(util.ISO8601),
200 },
201 })
202
203 s.evtman.AddEvent(context.TODO(), &events.XRPCStreamEvent{
204 RepoIdentity: &atproto.SyncSubscribeRepos_Identity{
205 Did: urepo.Did,
206 Handle: to.StringPtr(request.Handle),
207 Seq: time.Now().UnixMicro(), // TODO: no
208 Time: time.Now().Format(util.ISO8601),
209 },
210 })
211 }
212
213 if err := s.db.Raw("UPDATE invite_codes SET remaining_use_count = remaining_use_count - 1 WHERE code = ?", request.InviteCode).Scan(&ic).Error; err != nil {
214 s.logger.Error("error decrementing use count", "error", err)
215 return helpers.ServerError(e, nil)
216 }
217
218 sess, err := s.createSession(&urepo)
219 if err != nil {
220 s.logger.Error("error creating new session", "error", err)
221 return helpers.ServerError(e, nil)
222 }
223
224 go func() {
225 if err := s.sendEmailVerification(urepo.Email, actor.Handle, *urepo.EmailVerificationCode); err != nil {
226 s.logger.Error("error sending email verification email", "error", err)
227 }
228 if err := s.sendWelcomeMail(urepo.Email, actor.Handle); err != nil {
229 s.logger.Error("error sending welcome email", "error", err)
230 }
231 }()
232
233 return e.JSON(200, ComAtprotoServerCreateAccountResponse{
234 AccessJwt: sess.AccessToken,
235 RefreshJwt: sess.RefreshToken,
236 Handle: request.Handle,
237 Did: signupDid,
238 })
239}