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