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