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