1package server
2
3import (
4 "bytes"
5 "context"
6 "crypto/ecdsa"
7 "embed"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "net/http"
13 "net/smtp"
14 "os"
15 "path/filepath"
16 "sync"
17 "text/template"
18 "time"
19
20 "github.com/aws/aws-sdk-go/aws"
21 "github.com/aws/aws-sdk-go/aws/credentials"
22 "github.com/aws/aws-sdk-go/aws/session"
23 "github.com/aws/aws-sdk-go/service/s3"
24 "github.com/bluesky-social/indigo/api/atproto"
25 "github.com/bluesky-social/indigo/atproto/syntax"
26 "github.com/bluesky-social/indigo/events"
27 "github.com/bluesky-social/indigo/util"
28 "github.com/bluesky-social/indigo/xrpc"
29 "github.com/domodwyer/mailyak/v3"
30 "github.com/go-playground/validator"
31 "github.com/gorilla/sessions"
32 "github.com/haileyok/cocoon/identity"
33 "github.com/haileyok/cocoon/internal/db"
34 "github.com/haileyok/cocoon/internal/helpers"
35 "github.com/haileyok/cocoon/models"
36 "github.com/haileyok/cocoon/oauth/client"
37 "github.com/haileyok/cocoon/oauth/constants"
38 "github.com/haileyok/cocoon/oauth/dpop"
39 "github.com/haileyok/cocoon/oauth/provider"
40 "github.com/haileyok/cocoon/plc"
41 "github.com/ipfs/go-cid"
42 echo_session "github.com/labstack/echo-contrib/session"
43 "github.com/labstack/echo/v4"
44 "github.com/labstack/echo/v4/middleware"
45 slogecho "github.com/samber/slog-echo"
46 "gorm.io/driver/sqlite"
47 "gorm.io/gorm"
48)
49
50const (
51 AccountSessionMaxAge = 30 * 24 * time.Hour // one week
52)
53
54type S3Config struct {
55 BackupsEnabled bool
56 Endpoint string
57 Region string
58 Bucket string
59 AccessKey string
60 SecretKey string
61}
62
63type Server struct {
64 http *http.Client
65 httpd *http.Server
66 mail *mailyak.MailYak
67 mailLk *sync.Mutex
68 echo *echo.Echo
69 db *db.DB
70 plcClient *plc.Client
71 logger *slog.Logger
72 config *config
73 privateKey *ecdsa.PrivateKey
74 repoman *RepoMan
75 oauthProvider *provider.Provider
76 evtman *events.EventManager
77 passport *identity.Passport
78 fallbackProxy string
79
80 lastRequestCrawl time.Time
81 requestCrawlMu sync.Mutex
82
83 dbName string
84 s3Config *S3Config
85}
86
87type Args struct {
88 Addr string
89 DbName string
90 Logger *slog.Logger
91 Version string
92 Did string
93 Hostname string
94 RotationKeyPath string
95 JwkPath string
96 ContactEmail string
97 Relays []string
98 AdminPassword string
99
100 SmtpUser string
101 SmtpPass string
102 SmtpHost string
103 SmtpPort string
104 SmtpEmail string
105 SmtpName string
106
107 S3Config *S3Config
108
109 SessionSecret string
110
111 BlockstoreVariant BlockstoreVariant
112 FallbackProxy string
113}
114
115type config struct {
116 Version string
117 Did string
118 Hostname string
119 ContactEmail string
120 EnforcePeering bool
121 Relays []string
122 AdminPassword string
123 SmtpEmail string
124 SmtpName string
125 BlockstoreVariant BlockstoreVariant
126 FallbackProxy string
127}
128
129type CustomValidator struct {
130 validator *validator.Validate
131}
132
133type ValidationError struct {
134 error
135 Field string
136 Tag string
137}
138
139func (cv *CustomValidator) Validate(i any) error {
140 if err := cv.validator.Struct(i); err != nil {
141 var validateErrors validator.ValidationErrors
142 if errors.As(err, &validateErrors) && len(validateErrors) > 0 {
143 first := validateErrors[0]
144 return ValidationError{
145 error: err,
146 Field: first.Field(),
147 Tag: first.Tag(),
148 }
149 }
150
151 return err
152 }
153
154 return nil
155}
156
157//go:embed templates/*
158var templateFS embed.FS
159
160//go:embed static/*
161var staticFS embed.FS
162
163type TemplateRenderer struct {
164 templates *template.Template
165 isDev bool
166 templatePath string
167}
168
169func (s *Server) loadTemplates() {
170 absPath, _ := filepath.Abs("server/templates/*.html")
171 if s.config.Version == "dev" {
172 tmpl := template.Must(template.ParseGlob(absPath))
173 s.echo.Renderer = &TemplateRenderer{
174 templates: tmpl,
175 isDev: true,
176 templatePath: absPath,
177 }
178 } else {
179 tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html"))
180 s.echo.Renderer = &TemplateRenderer{
181 templates: tmpl,
182 isDev: false,
183 }
184 }
185}
186
187func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error {
188 if t.isDev {
189 tmpl, err := template.ParseGlob(t.templatePath)
190 if err != nil {
191 return err
192 }
193 t.templates = tmpl
194 }
195
196 if viewContext, isMap := data.(map[string]any); isMap {
197 viewContext["reverse"] = c.Echo().Reverse
198 }
199
200 return t.templates.ExecuteTemplate(w, name, data)
201}
202
203func New(args *Args) (*Server, error) {
204 if args.Addr == "" {
205 return nil, fmt.Errorf("addr must be set")
206 }
207
208 if args.DbName == "" {
209 return nil, fmt.Errorf("db name must be set")
210 }
211
212 if args.Did == "" {
213 return nil, fmt.Errorf("cocoon did must be set")
214 }
215
216 if args.ContactEmail == "" {
217 return nil, fmt.Errorf("cocoon contact email is required")
218 }
219
220 if _, err := syntax.ParseDID(args.Did); err != nil {
221 return nil, fmt.Errorf("error parsing cocoon did: %w", err)
222 }
223
224 if args.Hostname == "" {
225 return nil, fmt.Errorf("cocoon hostname must be set")
226 }
227
228 if args.AdminPassword == "" {
229 return nil, fmt.Errorf("admin password must be set")
230 }
231
232 if args.Logger == nil {
233 args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
234 }
235
236 if args.SessionSecret == "" {
237 panic("SESSION SECRET WAS NOT SET. THIS IS REQUIRED. ")
238 }
239
240 e := echo.New()
241
242 e.Pre(middleware.RemoveTrailingSlash())
243 e.Pre(slogecho.New(args.Logger))
244 e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret))))
245 e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
246 AllowOrigins: []string{"*"},
247 AllowHeaders: []string{"*"},
248 AllowMethods: []string{"*"},
249 AllowCredentials: true,
250 MaxAge: 100_000_000,
251 }))
252
253 vdtor := validator.New()
254 vdtor.RegisterValidation("atproto-handle", func(fl validator.FieldLevel) bool {
255 if _, err := syntax.ParseHandle(fl.Field().String()); err != nil {
256 return false
257 }
258 return true
259 })
260 vdtor.RegisterValidation("atproto-did", func(fl validator.FieldLevel) bool {
261 if _, err := syntax.ParseDID(fl.Field().String()); err != nil {
262 return false
263 }
264 return true
265 })
266 vdtor.RegisterValidation("atproto-rkey", func(fl validator.FieldLevel) bool {
267 if _, err := syntax.ParseRecordKey(fl.Field().String()); err != nil {
268 return false
269 }
270 return true
271 })
272 vdtor.RegisterValidation("atproto-nsid", func(fl validator.FieldLevel) bool {
273 if _, err := syntax.ParseNSID(fl.Field().String()); err != nil {
274 return false
275 }
276 return true
277 })
278
279 e.Validator = &CustomValidator{validator: vdtor}
280
281 httpd := &http.Server{
282 Addr: args.Addr,
283 Handler: e,
284 // shitty defaults but okay for now, needed for import repo
285 ReadTimeout: 5 * time.Minute,
286 WriteTimeout: 5 * time.Minute,
287 IdleTimeout: 5 * time.Minute,
288 }
289
290 gdb, err := gorm.Open(sqlite.Open(args.DbName), &gorm.Config{})
291 if err != nil {
292 return nil, err
293 }
294 dbw := db.NewDB(gdb)
295
296 rkbytes, err := os.ReadFile(args.RotationKeyPath)
297 if err != nil {
298 return nil, err
299 }
300
301 h := util.RobustHTTPClient()
302
303 plcClient, err := plc.NewClient(&plc.ClientArgs{
304 H: h,
305 Service: "https://plc.directory",
306 PdsHostname: args.Hostname,
307 RotationKey: rkbytes,
308 })
309 if err != nil {
310 return nil, err
311 }
312
313 jwkbytes, err := os.ReadFile(args.JwkPath)
314 if err != nil {
315 return nil, err
316 }
317
318 key, err := helpers.ParseJWKFromBytes(jwkbytes)
319 if err != nil {
320 return nil, err
321 }
322
323 var pkey ecdsa.PrivateKey
324 if err := key.Raw(&pkey); err != nil {
325 return nil, err
326 }
327
328 oauthCli := &http.Client{
329 Timeout: 10 * time.Second,
330 }
331
332 var nonceSecret []byte
333 maybeSecret, err := os.ReadFile("nonce.secret")
334 if err != nil && !os.IsNotExist(err) {
335 args.Logger.Error("error attempting to read nonce secret", "error", err)
336 } else {
337 nonceSecret = maybeSecret
338 }
339
340 s := &Server{
341 http: h,
342 httpd: httpd,
343 echo: e,
344 logger: args.Logger,
345 db: dbw,
346 plcClient: plcClient,
347 privateKey: &pkey,
348 config: &config{
349 Version: args.Version,
350 Did: args.Did,
351 Hostname: args.Hostname,
352 ContactEmail: args.ContactEmail,
353 EnforcePeering: false,
354 Relays: args.Relays,
355 AdminPassword: args.AdminPassword,
356 SmtpName: args.SmtpName,
357 SmtpEmail: args.SmtpEmail,
358 BlockstoreVariant: args.BlockstoreVariant,
359 FallbackProxy: args.FallbackProxy,
360 },
361 evtman: events.NewEventManager(events.NewMemPersister()),
362 passport: identity.NewPassport(h, identity.NewMemCache(10_000)),
363
364 dbName: args.DbName,
365 s3Config: args.S3Config,
366
367 oauthProvider: provider.NewProvider(provider.Args{
368 Hostname: args.Hostname,
369 ClientManagerArgs: client.ManagerArgs{
370 Cli: oauthCli,
371 Logger: args.Logger,
372 },
373 DpopManagerArgs: dpop.ManagerArgs{
374 NonceSecret: nonceSecret,
375 NonceRotationInterval: constants.NonceMaxRotationInterval / 3,
376 OnNonceSecretCreated: func(newNonce []byte) {
377 if err := os.WriteFile("nonce.secret", newNonce, 0644); err != nil {
378 args.Logger.Error("error writing new nonce secret", "error", err)
379 }
380 },
381 Logger: args.Logger,
382 Hostname: args.Hostname,
383 },
384 }),
385 }
386
387 s.loadTemplates()
388
389 s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it
390
391 // TODO: should validate these args
392 if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" {
393 args.Logger.Warn("not enough smtp args were provided. mailing will not work for your server.")
394 } else {
395 mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost))
396 mail.From(s.config.SmtpEmail)
397 mail.FromName(s.config.SmtpName)
398
399 s.mail = mail
400 s.mailLk = &sync.Mutex{}
401 }
402
403 return s, nil
404}
405
406func (s *Server) addRoutes() {
407 // static
408 if s.config.Version == "dev" {
409 s.echo.Static("/static", "server/static")
410 } else {
411 s.echo.GET("/static/*", echo.WrapHandler(http.FileServer(http.FS(staticFS))))
412 }
413
414 // random stuff
415 s.echo.GET("/", s.handleRoot)
416 s.echo.GET("/xrpc/_health", s.handleHealth)
417 s.echo.GET("/.well-known/did.json", s.handleWellKnown)
418 s.echo.GET("/.well-known/oauth-protected-resource", s.handleOauthProtectedResource)
419 s.echo.GET("/.well-known/oauth-authorization-server", s.handleOauthAuthorizationServer)
420 s.echo.GET("/robots.txt", s.handleRobots)
421
422 // public
423 s.echo.GET("/xrpc/com.atproto.identity.resolveHandle", s.handleResolveHandle)
424 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount)
425 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount)
426 s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession)
427 s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer)
428
429 s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo)
430 s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos)
431 s.echo.GET("/xrpc/com.atproto.repo.listRecords", s.handleListRecords)
432 s.echo.GET("/xrpc/com.atproto.repo.getRecord", s.handleRepoGetRecord)
433 s.echo.GET("/xrpc/com.atproto.sync.getRecord", s.handleSyncGetRecord)
434 s.echo.GET("/xrpc/com.atproto.sync.getBlocks", s.handleGetBlocks)
435 s.echo.GET("/xrpc/com.atproto.sync.getLatestCommit", s.handleSyncGetLatestCommit)
436 s.echo.GET("/xrpc/com.atproto.sync.getRepoStatus", s.handleSyncGetRepoStatus)
437 s.echo.GET("/xrpc/com.atproto.sync.getRepo", s.handleSyncGetRepo)
438 s.echo.GET("/xrpc/com.atproto.sync.subscribeRepos", s.handleSyncSubscribeRepos)
439 s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs)
440 s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob)
441
442 // account
443 s.echo.GET("/account", s.handleAccount)
444 s.echo.POST("/account/revoke", s.handleAccountRevoke)
445 s.echo.GET("/account/signin", s.handleAccountSigninGet)
446 s.echo.POST("/account/signin", s.handleAccountSigninPost)
447 s.echo.GET("/account/signout", s.handleAccountSignout)
448
449 // oauth account
450 s.echo.GET("/oauth/jwks", s.handleOauthJwks)
451 s.echo.GET("/oauth/authorize", s.handleOauthAuthorizeGet)
452 s.echo.POST("/oauth/authorize", s.handleOauthAuthorizePost)
453
454 // oauth authorization
455 s.echo.POST("/oauth/par", s.handleOauthPar, s.oauthProvider.BaseMiddleware)
456 s.echo.POST("/oauth/token", s.handleOauthToken, s.oauthProvider.BaseMiddleware)
457
458 // authed
459 s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
460 s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
461 s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
462 s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
463 s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
464 s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
465 s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE
466 s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
467 s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
468 s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
469 s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
470 s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
471 s.echo.POST("/xrpc/com.atproto.server.deactivateAccount", s.handleServerDeactivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
472 s.echo.POST("/xrpc/com.atproto.server.activateAccount", s.handleServerActivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
473
474 // repo
475 s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
476 s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
477 s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
478 s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
479 s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
480 s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
481
482 // stupid silly endpoints
483 s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
484 s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
485
486 // admin routes
487 s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware)
488 s.echo.POST("/xrpc/com.atproto.server.createInviteCodes", s.handleCreateInviteCodes, s.handleAdminMiddleware)
489
490 // are there any routes that we should be allowing without auth? i dont think so but idk
491 s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
492 s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
493}
494
495func (s *Server) Serve(ctx context.Context) error {
496 s.addRoutes()
497
498 s.logger.Info("migrating...")
499
500 s.db.AutoMigrate(
501 &models.Actor{},
502 &models.Repo{},
503 &models.InviteCode{},
504 &models.Token{},
505 &models.RefreshToken{},
506 &models.Block{},
507 &models.Record{},
508 &models.Blob{},
509 &models.BlobPart{},
510 &provider.OauthToken{},
511 &provider.OauthAuthorizationRequest{},
512 )
513
514 s.logger.Info("starting cocoon")
515
516 go func() {
517 if err := s.httpd.ListenAndServe(); err != nil {
518 panic(err)
519 }
520 }()
521
522 go s.backupRoutine()
523
524 go func() {
525 if err := s.requestCrawl(ctx); err != nil {
526 s.logger.Error("error requesting crawls", "err", err)
527 }
528 }()
529
530 <-ctx.Done()
531
532 fmt.Println("shut down")
533
534 return nil
535}
536
537func (s *Server) requestCrawl(ctx context.Context) error {
538 logger := s.logger.With("component", "request-crawl")
539 s.requestCrawlMu.Lock()
540 defer s.requestCrawlMu.Unlock()
541
542 logger.Info("requesting crawl with configured relays")
543
544 if time.Now().Sub(s.lastRequestCrawl) <= 1*time.Minute {
545 return fmt.Errorf("a crawl request has already been made within the last minute")
546 }
547
548 for _, relay := range s.config.Relays {
549 logger := logger.With("relay", relay)
550 logger.Info("requesting crawl from relay")
551 cli := xrpc.Client{Host: relay}
552 if err := atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{
553 Hostname: s.config.Hostname,
554 }); err != nil {
555 logger.Error("error requesting crawl", "err", err)
556 } else {
557 logger.Info("crawl requested successfully")
558 }
559 }
560
561 s.lastRequestCrawl = time.Now()
562
563 return nil
564}
565
566func (s *Server) doBackup() {
567 start := time.Now()
568
569 s.logger.Info("beginning backup to s3...")
570
571 var buf bytes.Buffer
572 if err := func() error {
573 s.logger.Info("reading database bytes...")
574 s.db.Lock()
575 defer s.db.Unlock()
576
577 sf, err := os.Open(s.dbName)
578 if err != nil {
579 return fmt.Errorf("error opening database for backup: %w", err)
580 }
581 defer sf.Close()
582
583 if _, err := io.Copy(&buf, sf); err != nil {
584 return fmt.Errorf("error reading bytes of backup db: %w", err)
585 }
586
587 return nil
588 }(); err != nil {
589 s.logger.Error("error backing up database", "error", err)
590 return
591 }
592
593 if err := func() error {
594 s.logger.Info("sending to s3...")
595
596 currTime := time.Now().Format("2006-01-02_15-04-05")
597 key := "cocoon-backup-" + currTime + ".db"
598
599 config := &aws.Config{
600 Region: aws.String(s.s3Config.Region),
601 Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""),
602 }
603
604 if s.s3Config.Endpoint != "" {
605 config.Endpoint = aws.String(s.s3Config.Endpoint)
606 config.S3ForcePathStyle = aws.Bool(true)
607 }
608
609 sess, err := session.NewSession(config)
610 if err != nil {
611 return err
612 }
613
614 svc := s3.New(sess)
615
616 if _, err := svc.PutObject(&s3.PutObjectInput{
617 Bucket: aws.String(s.s3Config.Bucket),
618 Key: aws.String(key),
619 Body: bytes.NewReader(buf.Bytes()),
620 }); err != nil {
621 return fmt.Errorf("error uploading file to s3: %w", err)
622 }
623
624 s.logger.Info("finished uploading backup to s3", "key", key, "duration", time.Now().Sub(start).Seconds())
625
626 return nil
627 }(); err != nil {
628 s.logger.Error("error uploading database backup", "error", err)
629 return
630 }
631
632 os.WriteFile("last-backup.txt", []byte(time.Now().String()), 0644)
633}
634
635func (s *Server) backupRoutine() {
636 if s.s3Config == nil || !s.s3Config.BackupsEnabled {
637 return
638 }
639
640 if s.s3Config.Region == "" {
641 s.logger.Warn("no s3 region configured but backups are enabled. backups will not run.")
642 return
643 }
644
645 if s.s3Config.Bucket == "" {
646 s.logger.Warn("no s3 bucket configured but backups are enabled. backups will not run.")
647 return
648 }
649
650 if s.s3Config.AccessKey == "" {
651 s.logger.Warn("no s3 access key configured but backups are enabled. backups will not run.")
652 return
653 }
654
655 if s.s3Config.SecretKey == "" {
656 s.logger.Warn("no s3 secret key configured but backups are enabled. backups will not run.")
657 return
658 }
659
660 shouldBackupNow := false
661 lastBackupStr, err := os.ReadFile("last-backup.txt")
662 if err != nil {
663 shouldBackupNow = true
664 } else {
665 lastBackup, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(lastBackupStr))
666 if err != nil {
667 shouldBackupNow = true
668 } else if time.Now().Sub(lastBackup).Seconds() > 3600 {
669 shouldBackupNow = true
670 }
671 }
672
673 if shouldBackupNow {
674 go s.doBackup()
675 }
676
677 ticker := time.NewTicker(time.Hour)
678 for range ticker.C {
679 go s.doBackup()
680 }
681}
682
683func (s *Server) UpdateRepo(ctx context.Context, did string, root cid.Cid, rev string) error {
684 if err := s.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, did).Error; err != nil {
685 return err
686 }
687
688 return nil
689}