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