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}