An atproto PDS written in Go
103
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 9fbd74c10e490237e8beb34e8e9732843b6bada3 656 lines 20 kB view raw
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 79 dbName string 80 s3Config *S3Config 81} 82 83type Args struct { 84 Addr string 85 DbName string 86 Logger *slog.Logger 87 Version string 88 Did string 89 Hostname string 90 RotationKeyPath string 91 JwkPath string 92 ContactEmail string 93 Relays []string 94 AdminPassword string 95 96 SmtpUser string 97 SmtpPass string 98 SmtpHost string 99 SmtpPort string 100 SmtpEmail string 101 SmtpName string 102 103 S3Config *S3Config 104 105 SessionSecret string 106 107 DefaultAtprotoProxy string 108 109 BlockstoreVariant BlockstoreVariant 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 DefaultAtprotoProxy string 123 BlockstoreVariant BlockstoreVariant 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 DefaultAtprotoProxy: args.DefaultAtprotoProxy, 356 BlockstoreVariant: args.BlockstoreVariant, 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 469 // repo 470 s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 471 s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 472 s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 473 s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 474 s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 475 s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 476 477 // stupid silly endpoints 478 s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 479 s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 480 481 // admin routes 482 s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware) 483 s.echo.POST("/xrpc/com.atproto.server.createInviteCodes", s.handleCreateInviteCodes, s.handleAdminMiddleware) 484 485 // are there any routes that we should be allowing without auth? i dont think so but idk 486 s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 487 s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 488} 489 490func (s *Server) Serve(ctx context.Context) error { 491 s.addRoutes() 492 493 s.logger.Info("migrating...") 494 495 s.db.AutoMigrate( 496 &models.Actor{}, 497 &models.Repo{}, 498 &models.InviteCode{}, 499 &models.Token{}, 500 &models.RefreshToken{}, 501 &models.Block{}, 502 &models.Record{}, 503 &models.Blob{}, 504 &models.BlobPart{}, 505 &provider.OauthToken{}, 506 &provider.OauthAuthorizationRequest{}, 507 ) 508 509 s.logger.Info("starting cocoon") 510 511 go func() { 512 if err := s.httpd.ListenAndServe(); err != nil { 513 panic(err) 514 } 515 }() 516 517 go s.backupRoutine() 518 519 for _, relay := range s.config.Relays { 520 cli := xrpc.Client{Host: relay} 521 atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{ 522 Hostname: s.config.Hostname, 523 }) 524 } 525 526 <-ctx.Done() 527 528 fmt.Println("shut down") 529 530 return nil 531} 532 533func (s *Server) doBackup() { 534 start := time.Now() 535 536 s.logger.Info("beginning backup to s3...") 537 538 var buf bytes.Buffer 539 if err := func() error { 540 s.logger.Info("reading database bytes...") 541 s.db.Lock() 542 defer s.db.Unlock() 543 544 sf, err := os.Open(s.dbName) 545 if err != nil { 546 return fmt.Errorf("error opening database for backup: %w", err) 547 } 548 defer sf.Close() 549 550 if _, err := io.Copy(&buf, sf); err != nil { 551 return fmt.Errorf("error reading bytes of backup db: %w", err) 552 } 553 554 return nil 555 }(); err != nil { 556 s.logger.Error("error backing up database", "error", err) 557 return 558 } 559 560 if err := func() error { 561 s.logger.Info("sending to s3...") 562 563 currTime := time.Now().Format("2006-01-02_15-04-05") 564 key := "cocoon-backup-" + currTime + ".db" 565 566 config := &aws.Config{ 567 Region: aws.String(s.s3Config.Region), 568 Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""), 569 } 570 571 if s.s3Config.Endpoint != "" { 572 config.Endpoint = aws.String(s.s3Config.Endpoint) 573 config.S3ForcePathStyle = aws.Bool(true) 574 } 575 576 sess, err := session.NewSession(config) 577 if err != nil { 578 return err 579 } 580 581 svc := s3.New(sess) 582 583 if _, err := svc.PutObject(&s3.PutObjectInput{ 584 Bucket: aws.String(s.s3Config.Bucket), 585 Key: aws.String(key), 586 Body: bytes.NewReader(buf.Bytes()), 587 }); err != nil { 588 return fmt.Errorf("error uploading file to s3: %w", err) 589 } 590 591 s.logger.Info("finished uploading backup to s3", "key", key, "duration", time.Now().Sub(start).Seconds()) 592 593 return nil 594 }(); err != nil { 595 s.logger.Error("error uploading database backup", "error", err) 596 return 597 } 598 599 os.WriteFile("last-backup.txt", []byte(time.Now().String()), 0644) 600} 601 602func (s *Server) backupRoutine() { 603 if s.s3Config == nil || !s.s3Config.BackupsEnabled { 604 return 605 } 606 607 if s.s3Config.Region == "" { 608 s.logger.Warn("no s3 region configured but backups are enabled. backups will not run.") 609 return 610 } 611 612 if s.s3Config.Bucket == "" { 613 s.logger.Warn("no s3 bucket configured but backups are enabled. backups will not run.") 614 return 615 } 616 617 if s.s3Config.AccessKey == "" { 618 s.logger.Warn("no s3 access key configured but backups are enabled. backups will not run.") 619 return 620 } 621 622 if s.s3Config.SecretKey == "" { 623 s.logger.Warn("no s3 secret key configured but backups are enabled. backups will not run.") 624 return 625 } 626 627 shouldBackupNow := false 628 lastBackupStr, err := os.ReadFile("last-backup.txt") 629 if err != nil { 630 shouldBackupNow = true 631 } else { 632 lastBackup, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(lastBackupStr)) 633 if err != nil { 634 shouldBackupNow = true 635 } else if time.Now().Sub(lastBackup).Seconds() > 3600 { 636 shouldBackupNow = true 637 } 638 } 639 640 if shouldBackupNow { 641 go s.doBackup() 642 } 643 644 ticker := time.NewTicker(time.Hour) 645 for range ticker.C { 646 go s.doBackup() 647 } 648} 649 650func (s *Server) UpdateRepo(ctx context.Context, did string, root cid.Cid, rev string) error { 651 if err := s.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, did).Error; err != nil { 652 return err 653 } 654 655 return nil 656}