this repo has no description
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}