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/haileyok/cocoon/sqlite_blockstore" 42 "github.com/ipfs/go-cid" 43 blockstore "github.com/ipfs/go-ipfs-blockstore" 44 echo_session "github.com/labstack/echo-contrib/session" 45 "github.com/labstack/echo/v4" 46 "github.com/labstack/echo/v4/middleware" 47 slogecho "github.com/samber/slog-echo" 48 "gorm.io/driver/sqlite" 49 "gorm.io/gorm" 50) 51 52const ( 53 AccountSessionMaxAge = 30 * 24 * time.Hour // one week 54) 55 56type S3Config struct { 57 BackupsEnabled bool 58 Endpoint string 59 Region string 60 Bucket string 61 AccessKey string 62 SecretKey string 63} 64 65type Server struct { 66 http *http.Client 67 httpd *http.Server 68 mail *mailyak.MailYak 69 mailLk *sync.Mutex 70 echo *echo.Echo 71 db *db.DB 72 plcClient *plc.Client 73 logger *slog.Logger 74 config *config 75 privateKey *ecdsa.PrivateKey 76 repoman *RepoMan 77 oauthProvider *provider.Provider 78 evtman *events.EventManager 79 passport *identity.Passport 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 DefaultAtprotoProxy 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 DefaultAtprotoProxy 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 DefaultAtprotoProxy: args.DefaultAtprotoProxy, 355 }, 356 evtman: events.NewEventManager(events.NewMemPersister()), 357 passport: identity.NewPassport(h, identity.NewMemCache(10_000)), 358 359 dbName: args.DbName, 360 s3Config: args.S3Config, 361 362 oauthProvider: provider.NewProvider(provider.Args{ 363 Hostname: args.Hostname, 364 ClientManagerArgs: client.ManagerArgs{ 365 Cli: oauthCli, 366 Logger: args.Logger, 367 }, 368 DpopManagerArgs: dpop.ManagerArgs{ 369 NonceSecret: nonceSecret, 370 NonceRotationInterval: constants.NonceMaxRotationInterval / 3, 371 OnNonceSecretCreated: func(newNonce []byte) { 372 if err := os.WriteFile("nonce.secret", newNonce, 0644); err != nil { 373 args.Logger.Error("error writing new nonce secret", "error", err) 374 } 375 }, 376 Logger: args.Logger, 377 Hostname: args.Hostname, 378 }, 379 }), 380 } 381 382 s.loadTemplates() 383 384 s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it 385 386 // TODO: should validate these args 387 if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" { 388 args.Logger.Warn("not enough smpt args were provided. mailing will not work for your server.") 389 } else { 390 mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost)) 391 mail.From(s.config.SmtpEmail) 392 mail.FromName(s.config.SmtpName) 393 394 s.mail = mail 395 s.mailLk = &sync.Mutex{} 396 } 397 398 return s, nil 399} 400 401func (s *Server) addRoutes() { 402 // static 403 if s.config.Version == "dev" { 404 s.echo.Static("/static", "server/static") 405 } else { 406 s.echo.GET("/static/*", echo.WrapHandler(http.FileServer(http.FS(staticFS)))) 407 } 408 409 // random stuff 410 s.echo.GET("/", s.handleRoot) 411 s.echo.GET("/xrpc/_health", s.handleHealth) 412 s.echo.GET("/.well-known/did.json", s.handleWellKnown) 413 s.echo.GET("/.well-known/oauth-protected-resource", s.handleOauthProtectedResource) 414 s.echo.GET("/.well-known/oauth-authorization-server", s.handleOauthAuthorizationServer) 415 s.echo.GET("/robots.txt", s.handleRobots) 416 417 // public 418 s.echo.GET("/xrpc/com.atproto.identity.resolveHandle", s.handleResolveHandle) 419 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) 420 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount) 421 s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession) 422 s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer) 423 424 s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo) 425 s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos) 426 s.echo.GET("/xrpc/com.atproto.repo.listRecords", s.handleListRecords) 427 s.echo.GET("/xrpc/com.atproto.repo.getRecord", s.handleRepoGetRecord) 428 s.echo.GET("/xrpc/com.atproto.sync.getRecord", s.handleSyncGetRecord) 429 s.echo.GET("/xrpc/com.atproto.sync.getBlocks", s.handleGetBlocks) 430 s.echo.GET("/xrpc/com.atproto.sync.getLatestCommit", s.handleSyncGetLatestCommit) 431 s.echo.GET("/xrpc/com.atproto.sync.getRepoStatus", s.handleSyncGetRepoStatus) 432 s.echo.GET("/xrpc/com.atproto.sync.getRepo", s.handleSyncGetRepo) 433 s.echo.GET("/xrpc/com.atproto.sync.subscribeRepos", s.handleSyncSubscribeRepos) 434 s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs) 435 s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob) 436 437 // account 438 s.echo.GET("/account", s.handleAccount) 439 s.echo.POST("/account/revoke", s.handleAccountRevoke) 440 s.echo.GET("/account/signin", s.handleAccountSigninGet) 441 s.echo.POST("/account/signin", s.handleAccountSigninPost) 442 s.echo.GET("/account/signout", s.handleAccountSignout) 443 444 // oauth account 445 s.echo.GET("/oauth/jwks", s.handleOauthJwks) 446 s.echo.GET("/oauth/authorize", s.handleOauthAuthorizeGet) 447 s.echo.POST("/oauth/authorize", s.handleOauthAuthorizePost) 448 449 // oauth authorization 450 s.echo.POST("/oauth/par", s.handleOauthPar, s.oauthProvider.BaseMiddleware) 451 s.echo.POST("/oauth/token", s.handleOauthToken, s.oauthProvider.BaseMiddleware) 452 453 // authed 454 s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 455 s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 456 s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 457 s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 458 s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 459 s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 460 s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE 461 s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 462 s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 463 s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 464 s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 465 s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 466 467 // repo 468 s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 469 s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 470 s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 471 s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 472 s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 473 s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 474 475 // stupid silly endpoints 476 s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 477 s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 478 479 // admin routes 480 s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware) 481 s.echo.POST("/xrpc/com.atproto.server.createInviteCodes", s.handleCreateInviteCodes, s.handleAdminMiddleware) 482 483 // are there any routes that we should be allowing without auth? i dont think so but idk 484 s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 485 s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 486} 487 488func (s *Server) Serve(ctx context.Context) error { 489 s.addRoutes() 490 491 s.logger.Info("migrating...") 492 493 s.db.AutoMigrate( 494 &models.Actor{}, 495 &models.Repo{}, 496 &models.InviteCode{}, 497 &models.Token{}, 498 &models.RefreshToken{}, 499 &models.Block{}, 500 &models.Record{}, 501 &models.Blob{}, 502 &models.BlobPart{}, 503 &provider.OauthToken{}, 504 &provider.OauthAuthorizationRequest{}, 505 ) 506 507 s.logger.Info("starting cocoon") 508 509 go func() { 510 if err := s.httpd.ListenAndServe(); err != nil { 511 panic(err) 512 } 513 }() 514 515 go s.backupRoutine() 516 517 for _, relay := range s.config.Relays { 518 cli := xrpc.Client{Host: relay} 519 atproto.SyncRequestCrawl(ctx, &cli, &atproto.SyncRequestCrawl_Input{ 520 Hostname: s.config.Hostname, 521 }) 522 } 523 524 <-ctx.Done() 525 526 fmt.Println("shut down") 527 528 return nil 529} 530 531func (s *Server) doBackup() { 532 start := time.Now() 533 534 s.logger.Info("beginning backup to s3...") 535 536 var buf bytes.Buffer 537 if err := func() error { 538 s.logger.Info("reading database bytes...") 539 s.db.Lock() 540 defer s.db.Unlock() 541 542 sf, err := os.Open(s.dbName) 543 if err != nil { 544 return fmt.Errorf("error opening database for backup: %w", err) 545 } 546 defer sf.Close() 547 548 if _, err := io.Copy(&buf, sf); err != nil { 549 return fmt.Errorf("error reading bytes of backup db: %w", err) 550 } 551 552 return nil 553 }(); err != nil { 554 s.logger.Error("error backing up database", "error", err) 555 return 556 } 557 558 if err := func() error { 559 s.logger.Info("sending to s3...") 560 561 currTime := time.Now().Format("2006-01-02_15-04-05") 562 key := "cocoon-backup-" + currTime + ".db" 563 564 config := &aws.Config{ 565 Region: aws.String(s.s3Config.Region), 566 Credentials: credentials.NewStaticCredentials(s.s3Config.AccessKey, s.s3Config.SecretKey, ""), 567 } 568 569 if s.s3Config.Endpoint != "" { 570 config.Endpoint = aws.String(s.s3Config.Endpoint) 571 config.S3ForcePathStyle = aws.Bool(true) 572 } 573 574 sess, err := session.NewSession(config) 575 if err != nil { 576 return err 577 } 578 579 svc := s3.New(sess) 580 581 if _, err := svc.PutObject(&s3.PutObjectInput{ 582 Bucket: aws.String(s.s3Config.Bucket), 583 Key: aws.String(key), 584 Body: bytes.NewReader(buf.Bytes()), 585 }); err != nil { 586 return fmt.Errorf("error uploading file to s3: %w", err) 587 } 588 589 s.logger.Info("finished uploading backup to s3", "key", key, "duration", time.Now().Sub(start).Seconds()) 590 591 return nil 592 }(); err != nil { 593 s.logger.Error("error uploading database backup", "error", err) 594 return 595 } 596 597 os.WriteFile("last-backup.txt", []byte(time.Now().String()), 0644) 598} 599 600func (s *Server) backupRoutine() { 601 if s.s3Config == nil || !s.s3Config.BackupsEnabled { 602 return 603 } 604 605 if s.s3Config.Region == "" { 606 s.logger.Warn("no s3 region configured but backups are enabled. backups will not run.") 607 return 608 } 609 610 if s.s3Config.Bucket == "" { 611 s.logger.Warn("no s3 bucket configured but backups are enabled. backups will not run.") 612 return 613 } 614 615 if s.s3Config.AccessKey == "" { 616 s.logger.Warn("no s3 access key configured but backups are enabled. backups will not run.") 617 return 618 } 619 620 if s.s3Config.SecretKey == "" { 621 s.logger.Warn("no s3 secret key configured but backups are enabled. backups will not run.") 622 return 623 } 624 625 shouldBackupNow := false 626 lastBackupStr, err := os.ReadFile("last-backup.txt") 627 if err != nil { 628 shouldBackupNow = true 629 } else { 630 lastBackup, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", string(lastBackupStr)) 631 if err != nil { 632 shouldBackupNow = true 633 } else if time.Now().Sub(lastBackup).Seconds() > 3600 { 634 shouldBackupNow = true 635 } 636 } 637 638 if shouldBackupNow { 639 go s.doBackup() 640 } 641 642 ticker := time.NewTicker(time.Hour) 643 for range ticker.C { 644 go s.doBackup() 645 } 646} 647 648func (s *Server) createBlockstore(did string) blockstore.Blockstore { 649 // TODO: eventually configurable blockstore types here 650 return sqlite_blockstore.New(did, s.db) 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}