An atproto PDS written in Go
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}