+36
cmd/cocoon/main.go
+36
cmd/cocoon/main.go
···
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
+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
+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
+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
+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
+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
+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
+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
+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
}