An atproto PDS written in Go

email verification

+36
cmd/cocoon/main.go
···
Required: true,
EnvVars: []string{"COCOON_RELAYS"},
},
+
&cli.StringFlag{
+
Name: "smtp-user",
+
Required: false,
+
EnvVars: []string{"COCOON_SMTP_USER"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-pass",
+
Required: false,
+
EnvVars: []string{"COCOON_SMTP_PASS"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-host",
+
Required: false,
+
EnvVars: []string{"COCOON_SMTP_HOST"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-port",
+
Required: false,
+
EnvVars: []string{"COCOON_SMTP_PORT"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-email",
+
Required: false,
+
EnvVars: []string{"COCOON_SMTP_EMAIL"},
+
},
+
&cli.StringFlag{
+
Name: "smtp-name",
+
Required: false,
+
EnvVars: []string{"COCOON_SMTP_NAME"},
+
},
},
Commands: []*cli.Command{
run,
···
ContactEmail: cmd.String("contact-email"),
Version: Version,
Relays: cmd.StringSlice("relays"),
+
SmtpUser: cmd.String("smtp-user"),
+
SmtpPass: cmd.String("smtp-pass"),
+
SmtpHost: cmd.String("smtp-host"),
+
SmtpPort: cmd.String("smtp-port"),
+
SmtpEmail: cmd.String("smtp-email"),
+
SmtpName: cmd.String("smtp-name"),
})
if err != nil {
fmt.Printf("error creating cocoon: %v", err)
+1
go.mod
···
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
+
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
+2
go.sum
···
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
+
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
+
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+10 -9
models/models.go
···
)
type Repo struct {
-
Did string `gorm:"primaryKey"`
-
CreatedAt time.Time
-
Email string `gorm:"uniqueIndex"`
-
EmailConfirmedAt *time.Time
-
Password string
-
SigningKey []byte
-
Rev string
-
Root []byte
-
Preferences []byte
+
Did string `gorm:"primaryKey"`
+
CreatedAt time.Time
+
Email string `gorm:"uniqueIndex"`
+
EmailConfirmedAt *time.Time
+
EmailVerificationCode *string
+
Password string
+
SigningKey []byte
+
Rev string
+
Root []byte
+
Preferences []byte
}
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
+42
server/handle_server_confirm_email.go
···
+
package server
+
+
import (
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
type ComAtprotoServerConfirmEmailRequest struct {
+
Email string `json:"email" validate:"required"`
+
Token string `json:"token" validate:"required"`
+
}
+
+
func (s *Server) handleServerConfirmEmail(e echo.Context) error {
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
var req ComAtprotoServerConfirmEmailRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := e.Validate(req); err != nil {
+
return helpers.InputError(e, nil)
+
}
+
+
if urepo.EmailVerificationCode == nil {
+
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
+
}
+
+
if *urepo.EmailVerificationCode != req.Token {
+
return helpers.InputError(e, to.StringPtr("InvalidToken"))
+
}
+
+
if err := s.db.Exec("UPDATE repos SET email_verification_token = NULL, email_confirmed_at = NOW() WHERE did = ?", urepo.Repo.Did).Error; err != nil {
+
s.logger.Error("error updating user", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
return e.NoContent(200)
+
}
+16 -5
server/handle_server_create_account.go
···
import (
"context"
"errors"
+
"fmt"
"strings"
"time"
···
}
urepo := models.Repo{
-
Did: did,
-
CreatedAt: time.Now(),
-
Email: request.Email,
-
Password: string(hashed),
-
SigningKey: k.Bytes(),
+
Did: did,
+
CreatedAt: time.Now(),
+
Email: request.Email,
+
EmailVerificationCode: to.StringPtr(fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))),
+
Password: string(hashed),
+
SigningKey: k.Bytes(),
}
actor := models.Actor{
···
s.logger.Error("error creating new session", "error", err)
return helpers.ServerError(e, nil)
}
+
+
go func() {
+
if err := s.sendEmailVerification(urepo.Email, actor.Handle, *urepo.EmailVerificationCode); err != nil {
+
s.logger.Error("error sending email verification email", "error", err)
+
}
+
if err := s.sendWelcomeMail(urepo.Email, actor.Handle); err != nil {
+
s.logger.Error("error sending welcome email", "error", err)
+
}
+
}()
return e.JSON(200, ComAtprotoServerCreateAccountResponse{
AccessJwt: sess.AccessToken,
+32
server/handle_server_request_email_confirmation.go
···
+
package server
+
+
import (
+
"fmt"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleServerRequestEmailConfirmation(e echo.Context) error {
+
urepo := e.Get("repo").(*models.RepoActor)
+
+
if urepo.EmailConfirmedAt != nil {
+
return helpers.InputError(e, to.StringPtr("InvalidRequest"))
+
}
+
+
code := fmt.Sprintf("%s-%s", helpers.RandomVarchar(6), helpers.RandomVarchar(6))
+
+
if err := s.db.Exec("UPDATE repos SET email_verification_code = ? WHERE did = ?", code, urepo.Repo.Did).Error; err != nil {
+
s.logger.Error("error updating user", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := s.sendEmailVerification(urepo.Email, urepo.Handle, code); err != nil {
+
s.logger.Error("error sending mail", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
return e.NoContent(200)
+
}
+48
server/mail.go
···
+
package server
+
+
import "fmt"
+
+
func (s *Server) sendWelcomeMail(email, handle string) error {
+
s.mailLk.Lock()
+
defer s.mailLk.Unlock()
+
+
s.mail.To(email)
+
s.mail.Subject("Welcome to " + s.config.Hostname)
+
s.mail.Plain().Set(fmt.Sprintf("Welcome to %s! Your handle is %s.", email, handle))
+
+
if err := s.mail.Send(); err != nil {
+
return err
+
}
+
+
return nil
+
}
+
+
func (s *Server) sendPasswordReset(email, handle, code string) error {
+
s.mailLk.Lock()
+
defer s.mailLk.Unlock()
+
+
s.mail.To(email)
+
s.mail.Subject("Password reset for " + s.config.Hostname)
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your password reset code is %s.", handle, code))
+
+
if err := s.mail.Send(); err != nil {
+
return err
+
}
+
+
return nil
+
}
+
+
func (s *Server) sendEmailVerification(email, handle, code string) error {
+
s.mailLk.Lock()
+
defer s.mailLk.Unlock()
+
+
s.mail.To(email)
+
s.mail.Subject("Email verification for " + s.config.Hostname)
+
s.mail.Plain().Set(fmt.Sprintf("Hello %s. Your email verification code is %s", handle, code))
+
+
if err := s.mail.Send(); err != nil {
+
return err
+
}
+
+
return nil
+
}
+35 -4
server/server.go
···
"fmt"
"log/slog"
"net/http"
+
"net/smtp"
"os"
"strings"
+
"sync"
"time"
"github.com/Azure/go-autorest/autorest/to"
···
"github.com/bluesky-social/indigo/events"
"github.com/bluesky-social/indigo/util"
"github.com/bluesky-social/indigo/xrpc"
+
"github.com/domodwyer/mailyak/v3"
"github.com/go-playground/validator"
"github.com/golang-jwt/jwt/v4"
"github.com/haileyok/cocoon/identity"
···
type Server struct {
http *http.Client
httpd *http.Server
+
mail *mailyak.MailYak
+
mailLk *sync.Mutex
echo *echo.Echo
db *gorm.DB
plcClient *plc.Client
···
JwkPath string
ContactEmail string
Relays []string
+
+
SmtpUser string
+
SmtpPass string
+
SmtpHost string
+
SmtpPort string
+
SmtpEmail string
+
SmtpName string
}
type config struct {
···
ContactEmail string
EnforcePeering bool
Relays []string
+
SmtpEmail string
+
SmtpName string
}
type CustomValidator struct {
···
return helpers.InputError(e, to.StringPtr("ExpiredToken"))
}
-
e.Set("did", claims["sub"])
-
repo, err := s.getRepoActorByDid(claims["sub"].(string))
if err != nil {
s.logger.Error("error fetching repo", "error", err)
return helpers.ServerError(e, nil)
}
+
e.Set("repo", repo)
-
+
e.Set("did", claims["sub"])
e.Set("token", tokenstr)
if err := next(e); err != nil {
···
ContactEmail: args.ContactEmail,
EnforcePeering: false,
Relays: args.Relays,
+
SmtpName: args.SmtpName,
+
SmtpEmail: args.SmtpEmail,
},
evtman: events.NewEventManager(events.NewMemPersister()),
passport: identity.NewPassport(h, identity.NewMemCache(10_000)),
···
s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it
+
// TODO: should validate these args
+
if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" {
+
args.Logger.Warn("not enough smpt args were provided. mailing will not work for your server.")
+
} else {
+
mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost))
+
mail.From(s.config.SmtpEmail)
+
mail.From(s.config.SmtpName)
+
+
s.mail = mail
+
s.mailLk = &sync.Mutex{}
+
}
+
return s, nil
}
func (s *Server) addRoutes() {
+
// random stuff
s.echo.GET("/", s.handleRoot)
s.echo.GET("/xrpc/_health", s.handleHealth)
s.echo.GET("/.well-known/did.json", s.handleWellKnown)
···
s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware)
s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware)
s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleSessionMiddleware)
// repo
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware)
···
s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware)
s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware)
+
// are there any routes that we should be allowing without auth? i dont think so but idk
s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
}
···
for _, relay := range s.config.Relays {
cli := xrpc.Client{Host: relay}
-
atproto.SyncRequestCrawl(context.TODO(), &cli, &atproto.SyncRequestCrawl_Input{
+
atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{
Hostname: s.config.Hostname,
})
}