An atproto PDS written in Go

email verification

+36
cmd/cocoon/main.go
··· 56 Required: true, 57 EnvVars: []string{"COCOON_RELAYS"}, 58 }, 59 }, 60 Commands: []*cli.Command{ 61 run, ··· 82 ContactEmail: cmd.String("contact-email"), 83 Version: Version, 84 Relays: cmd.StringSlice("relays"), 85 }) 86 if err != nil { 87 fmt.Printf("error creating cocoon: %v", err)
··· 56 Required: true, 57 EnvVars: []string{"COCOON_RELAYS"}, 58 }, 59 + &cli.StringFlag{ 60 + Name: "smtp-user", 61 + Required: false, 62 + EnvVars: []string{"COCOON_SMTP_USER"}, 63 + }, 64 + &cli.StringFlag{ 65 + Name: "smtp-pass", 66 + Required: false, 67 + EnvVars: []string{"COCOON_SMTP_PASS"}, 68 + }, 69 + &cli.StringFlag{ 70 + Name: "smtp-host", 71 + Required: false, 72 + EnvVars: []string{"COCOON_SMTP_HOST"}, 73 + }, 74 + &cli.StringFlag{ 75 + Name: "smtp-port", 76 + Required: false, 77 + EnvVars: []string{"COCOON_SMTP_PORT"}, 78 + }, 79 + &cli.StringFlag{ 80 + Name: "smtp-email", 81 + Required: false, 82 + EnvVars: []string{"COCOON_SMTP_EMAIL"}, 83 + }, 84 + &cli.StringFlag{ 85 + Name: "smtp-name", 86 + Required: false, 87 + EnvVars: []string{"COCOON_SMTP_NAME"}, 88 + }, 89 }, 90 Commands: []*cli.Command{ 91 run, ··· 112 ContactEmail: cmd.String("contact-email"), 113 Version: Version, 114 Relays: cmd.StringSlice("relays"), 115 + SmtpUser: cmd.String("smtp-user"), 116 + SmtpPass: cmd.String("smtp-pass"), 117 + SmtpHost: cmd.String("smtp-host"), 118 + SmtpPort: cmd.String("smtp-port"), 119 + SmtpEmail: cmd.String("smtp-email"), 120 + SmtpName: cmd.String("smtp-name"), 121 }) 122 if err != nil { 123 fmt.Printf("error creating cocoon: %v", err)
+1
go.mod
··· 32 github.com/cespare/xxhash/v2 v2.2.0 // indirect 33 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 34 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 35 github.com/felixge/httpsnoop v1.0.4 // indirect 36 github.com/go-logr/logr v1.4.2 // indirect 37 github.com/go-logr/stdr v1.2.2 // indirect
··· 32 github.com/cespare/xxhash/v2 v2.2.0 // indirect 33 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 34 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 35 + github.com/domodwyer/mailyak/v3 v3.6.2 // indirect 36 github.com/felixge/httpsnoop v1.0.4 // indirect 37 github.com/go-logr/logr v1.4.2 // indirect 38 github.com/go-logr/stdr v1.2.2 // indirect
+2
go.sum
··· 37 github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 38 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 39 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 40 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 41 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 42 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
··· 37 github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 38 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 39 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 40 + github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= 41 + github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= 42 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 43 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 44 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+10 -9
models/models.go
··· 8 ) 9 10 type Repo struct { 11 - Did string `gorm:"primaryKey"` 12 - CreatedAt time.Time 13 - Email string `gorm:"uniqueIndex"` 14 - EmailConfirmedAt *time.Time 15 - Password string 16 - SigningKey []byte 17 - Rev string 18 - Root []byte 19 - Preferences []byte 20 } 21 22 func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
··· 8 ) 9 10 type Repo struct { 11 + Did string `gorm:"primaryKey"` 12 + CreatedAt time.Time 13 + Email string `gorm:"uniqueIndex"` 14 + EmailConfirmedAt *time.Time 15 + EmailVerificationCode *string 16 + Password string 17 + SigningKey []byte 18 + Rev string 19 + Root []byte 20 + Preferences []byte 21 } 22 23 func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
+42
server/handle_server_confirm_email.go
···
··· 1 + package server 2 + 3 + import ( 4 + "github.com/Azure/go-autorest/autorest/to" 5 + "github.com/haileyok/cocoon/internal/helpers" 6 + "github.com/haileyok/cocoon/models" 7 + "github.com/labstack/echo/v4" 8 + ) 9 + 10 + type ComAtprotoServerConfirmEmailRequest struct { 11 + Email string `json:"email" validate:"required"` 12 + Token string `json:"token" validate:"required"` 13 + } 14 + 15 + func (s *Server) handleServerConfirmEmail(e echo.Context) error { 16 + urepo := e.Get("repo").(*models.RepoActor) 17 + 18 + var req ComAtprotoServerConfirmEmailRequest 19 + if err := e.Bind(&req); err != nil { 20 + s.logger.Error("error binding", "error", err) 21 + return helpers.ServerError(e, nil) 22 + } 23 + 24 + if err := e.Validate(req); err != nil { 25 + return helpers.InputError(e, nil) 26 + } 27 + 28 + if urepo.EmailVerificationCode == nil { 29 + return helpers.InputError(e, to.StringPtr("ExpiredToken")) 30 + } 31 + 32 + if *urepo.EmailVerificationCode != req.Token { 33 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 34 + } 35 + 36 + if err := s.db.Exec("UPDATE repos SET email_verification_token = NULL, email_confirmed_at = NOW() WHERE did = ?", urepo.Repo.Did).Error; err != nil { 37 + s.logger.Error("error updating user", "error", err) 38 + return helpers.ServerError(e, nil) 39 + } 40 + 41 + return e.NoContent(200) 42 + }
+16 -5
server/handle_server_create_account.go
··· 3 import ( 4 "context" 5 "errors" 6 "strings" 7 "time" 8 ··· 134 } 135 136 urepo := models.Repo{ 137 - Did: did, 138 - CreatedAt: time.Now(), 139 - Email: request.Email, 140 - Password: string(hashed), 141 - SigningKey: k.Bytes(), 142 } 143 144 actor := models.Actor{ ··· 198 s.logger.Error("error creating new session", "error", err) 199 return helpers.ServerError(e, nil) 200 } 201 202 return e.JSON(200, ComAtprotoServerCreateAccountResponse{ 203 AccessJwt: sess.AccessToken,
··· 3 import ( 4 "context" 5 "errors" 6 + "fmt" 7 "strings" 8 "time" 9 ··· 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{ ··· 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,
+32
server/handle_server_request_email_confirmation.go
···
··· 1 + package server 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/Azure/go-autorest/autorest/to" 7 + "github.com/haileyok/cocoon/internal/helpers" 8 + "github.com/haileyok/cocoon/models" 9 + "github.com/labstack/echo/v4" 10 + ) 11 + 12 + func (s *Server) handleServerRequestEmailConfirmation(e echo.Context) error { 13 + urepo := e.Get("repo").(*models.RepoActor) 14 + 15 + if urepo.EmailConfirmedAt != nil { 16 + return helpers.InputError(e, to.StringPtr("InvalidRequest")) 17 + } 18 + 19 + code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6)) 20 + 21 + if err := s.db.Exec("UPDATE repos SET email_verification_code = ? WHERE did = ?", code, urepo.Repo.Did).Error; err != nil { 22 + s.logger.Error("error updating user", "error", err) 23 + return helpers.ServerError(e, nil) 24 + } 25 + 26 + if err := s.sendEmailVerification(urepo.Email, urepo.Handle, code); err != nil { 27 + s.logger.Error("error sending mail", "error", err) 28 + return helpers.ServerError(e, nil) 29 + } 30 + 31 + return e.NoContent(200) 32 + }
+48
server/mail.go
···
··· 1 + package server 2 + 3 + import "fmt" 4 + 5 + func (s *Server) sendWelcomeMail(email, handle string) error { 6 + s.mailLk.Lock() 7 + defer s.mailLk.Unlock() 8 + 9 + s.mail.To(email) 10 + s.mail.Subject("Welcome to " + s.config.Hostname) 11 + s.mail.Plain().Set(fmt.Sprintf("Welcome to %s! Your handle is %s.", email, handle)) 12 + 13 + if err := s.mail.Send(); err != nil { 14 + return err 15 + } 16 + 17 + return nil 18 + } 19 + 20 + func (s *Server) sendPasswordReset(email, handle, code string) error { 21 + s.mailLk.Lock() 22 + defer s.mailLk.Unlock() 23 + 24 + s.mail.To(email) 25 + s.mail.Subject("Password reset for " + s.config.Hostname) 26 + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your password reset code is %s.", handle, code)) 27 + 28 + if err := s.mail.Send(); err != nil { 29 + return err 30 + } 31 + 32 + return nil 33 + } 34 + 35 + func (s *Server) sendEmailVerification(email, handle, code string) error { 36 + s.mailLk.Lock() 37 + defer s.mailLk.Unlock() 38 + 39 + s.mail.To(email) 40 + s.mail.Subject("Email verification for " + s.config.Hostname) 41 + s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your email verification code is %s", handle, code)) 42 + 43 + if err := s.mail.Send(); err != nil { 44 + return err 45 + } 46 + 47 + return nil 48 + }
+35 -4
server/server.go
··· 7 "fmt" 8 "log/slog" 9 "net/http" 10 "os" 11 "strings" 12 "time" 13 14 "github.com/Azure/go-autorest/autorest/to" ··· 17 "github.com/bluesky-social/indigo/events" 18 "github.com/bluesky-social/indigo/util" 19 "github.com/bluesky-social/indigo/xrpc" 20 "github.com/go-playground/validator" 21 "github.com/golang-jwt/jwt/v4" 22 "github.com/haileyok/cocoon/identity" ··· 34 type Server struct { 35 http *http.Client 36 httpd *http.Server 37 echo *echo.Echo 38 db *gorm.DB 39 plcClient *plc.Client ··· 56 JwkPath string 57 ContactEmail string 58 Relays []string 59 } 60 61 type config struct { ··· 65 ContactEmail string 66 EnforcePeering bool 67 Relays []string 68 } 69 70 type CustomValidator struct { ··· 167 return helpers.InputError(e, to.StringPtr("ExpiredToken")) 168 } 169 170 - e.Set("did", claims["sub"]) 171 - 172 repo, err := s.getRepoActorByDid(claims["sub"].(string)) 173 if err != nil { 174 s.logger.Error("error fetching repo", "error", err) 175 return helpers.ServerError(e, nil) 176 } 177 e.Set("repo", repo) 178 - 179 e.Set("token", tokenstr) 180 181 if err := next(e); err != nil { ··· 312 ContactEmail: args.ContactEmail, 313 EnforcePeering: false, 314 Relays: args.Relays, 315 }, 316 evtman: events.NewEventManager(events.NewMemPersister()), 317 passport: identity.NewPassport(h, identity.NewMemCache(10_000)), ··· 319 320 s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it 321 322 return s, nil 323 } 324 325 func (s *Server) addRoutes() { 326 s.echo.GET("/", s.handleRoot) 327 s.echo.GET("/xrpc/_health", s.handleHealth) 328 s.echo.GET("/.well-known/did.json", s.handleWellKnown) ··· 353 s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware) 354 s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware) 355 s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware) 356 357 // repo 358 s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware) ··· 364 s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware) 365 s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware) 366 367 s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 368 s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 369 } ··· 395 396 for _, relay := range s.config.Relays { 397 cli := xrpc.Client{Host: relay} 398 - atproto.SyncRequestCrawl(context.TODO(), &cli, &atproto.SyncRequestCrawl_Input{ 399 Hostname: s.config.Hostname, 400 }) 401 }
··· 7 "fmt" 8 "log/slog" 9 "net/http" 10 + "net/smtp" 11 "os" 12 "strings" 13 + "sync" 14 "time" 15 16 "github.com/Azure/go-autorest/autorest/to" ··· 19 "github.com/bluesky-social/indigo/events" 20 "github.com/bluesky-social/indigo/util" 21 "github.com/bluesky-social/indigo/xrpc" 22 + "github.com/domodwyer/mailyak/v3" 23 "github.com/go-playground/validator" 24 "github.com/golang-jwt/jwt/v4" 25 "github.com/haileyok/cocoon/identity" ··· 37 type Server struct { 38 http *http.Client 39 httpd *http.Server 40 + mail *mailyak.MailYak 41 + mailLk *sync.Mutex 42 echo *echo.Echo 43 db *gorm.DB 44 plcClient *plc.Client ··· 61 JwkPath string 62 ContactEmail string 63 Relays []string 64 + 65 + SmtpUser string 66 + SmtpPass string 67 + SmtpHost string 68 + SmtpPort string 69 + SmtpEmail string 70 + SmtpName string 71 } 72 73 type config struct { ··· 77 ContactEmail string 78 EnforcePeering bool 79 Relays []string 80 + SmtpEmail string 81 + SmtpName string 82 } 83 84 type CustomValidator struct { ··· 181 return helpers.InputError(e, to.StringPtr("ExpiredToken")) 182 } 183 184 repo, err := s.getRepoActorByDid(claims["sub"].(string)) 185 if err != nil { 186 s.logger.Error("error fetching repo", "error", err) 187 return helpers.ServerError(e, nil) 188 } 189 + 190 e.Set("repo", repo) 191 + e.Set("did", claims["sub"]) 192 e.Set("token", tokenstr) 193 194 if err := next(e); err != nil { ··· 325 ContactEmail: args.ContactEmail, 326 EnforcePeering: false, 327 Relays: args.Relays, 328 + SmtpName: args.SmtpName, 329 + SmtpEmail: args.SmtpEmail, 330 }, 331 evtman: events.NewEventManager(events.NewMemPersister()), 332 passport: identity.NewPassport(h, identity.NewMemCache(10_000)), ··· 334 335 s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it 336 337 + // TODO: should validate these args 338 + if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" { 339 + args.Logger.Warn("not enough smpt args were provided. mailing will not work for your server.") 340 + } else { 341 + mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost)) 342 + mail.From(s.config.SmtpEmail) 343 + mail.From(s.config.SmtpName) 344 + 345 + s.mail = mail 346 + s.mailLk = &sync.Mutex{} 347 + } 348 + 349 return s, nil 350 } 351 352 func (s *Server) addRoutes() { 353 + // random stuff 354 s.echo.GET("/", s.handleRoot) 355 s.echo.GET("/xrpc/_health", s.handleHealth) 356 s.echo.GET("/.well-known/did.json", s.handleWellKnown) ··· 381 s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware) 382 s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware) 383 s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware) 384 + s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleSessionMiddleware) 385 + s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleSessionMiddleware) 386 387 // repo 388 s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware) ··· 394 s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware) 395 s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware) 396 397 + // are there any routes that we should be allowing without auth? i dont think so but idk 398 s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 399 s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 400 } ··· 426 427 for _, relay := range s.config.Relays { 428 cli := xrpc.Client{Host: relay} 429 + atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{ 430 Hostname: s.config.Hostname, 431 }) 432 }