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/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 = ?", nil, 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, nil).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, nil).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 = ?", nil, 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}