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