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
79 dbName string
80 s3Config *S3Config
81}
82
83type Args struct {
84 Addr string
85 DbName string
86 Logger *slog.Logger
87 Version string
88 Did string
89 Hostname string
90 RotationKeyPath string
91 JwkPath string
92 ContactEmail string
93 Relays []string
94 AdminPassword string
95
96 SmtpUser string
97 SmtpPass string
98 SmtpHost string
99 SmtpPort string
100 SmtpEmail string
101 SmtpName string
102
103 S3Config *S3Config
104
105 SessionSecret string
106
107 BlockstoreVariant BlockstoreVariant
108}
109
110type config struct {
111 Version string
112 Did string
113 Hostname string
114 ContactEmail string
115 EnforcePeering bool
116 Relays []string
117 AdminPassword string
118 SmtpEmail string
119 SmtpName string
120 BlockstoreVariant BlockstoreVariant
121}
122
123type CustomValidator struct {
124 validator *validator.Validate
125}
126
127type ValidationError struct {
128 error
129 Field string
130 Tag string
131}
132
133func (cv *CustomValidator) Validate(i any) error {
134 if err := cv.validator.Struct(i); err != nil {
135 var validateErrors validator.ValidationErrors
136 if errors.As(err, &validateErrors) && len(validateErrors) > 0 {
137 first := validateErrors[0]
138 return ValidationError{
139 error: err,
140 Field: first.Field(),
141 Tag: first.Tag(),
142 }
143 }
144
145 return err
146 }
147
148 return nil
149}
150
151//go:embed templates/*
152var templateFS embed.FS
153
154//go:embed static/*
155var staticFS embed.FS
156
157type TemplateRenderer struct {
158 templates *template.Template
159 isDev bool
160 templatePath string
161}
162
163func (s *Server) loadTemplates() {
164 absPath, _ := filepath.Abs("server/templates/*.html")
165 if s.config.Version == "dev" {
166 tmpl := template.Must(template.ParseGlob(absPath))
167 s.echo.Renderer = &TemplateRenderer{
168 templates: tmpl,
169 isDev: true,
170 templatePath: absPath,
171 }
172 } else {
173 tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html"))
174 s.echo.Renderer = &TemplateRenderer{
175 templates: tmpl,
176 isDev: false,
177 }
178 }
179}
180
181func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error {
182 if t.isDev {
183 tmpl, err := template.ParseGlob(t.templatePath)
184 if err != nil {
185 return err
186 }
187 t.templates = tmpl
188 }
189
190 if viewContext, isMap := data.(map[string]any); isMap {
191 viewContext["reverse"] = c.Echo().Reverse
192 }
193
194 return t.templates.ExecuteTemplate(w, name, data)
195}
196
197func New(args *Args) (*Server, error) {
198 if args.Addr == "" {
199 return nil, fmt.Errorf("addr must be set")
200 }
201
202 if args.DbName == "" {
203 return nil, fmt.Errorf("db name must be set")
204 }
205
206 if args.Did == "" {
207 return nil, fmt.Errorf("cocoon did must be set")
208 }
209
210 if args.ContactEmail == "" {
211 return nil, fmt.Errorf("cocoon contact email is required")
212 }
213
214 if _, err := syntax.ParseDID(args.Did); err != nil {
215 return nil, fmt.Errorf("error parsing cocoon did: %w", err)
216 }
217
218 if args.Hostname == "" {
219 return nil, fmt.Errorf("cocoon hostname must be set")
220 }
221
222 if args.AdminPassword == "" {
223 return nil, fmt.Errorf("admin password must be set")
224 }
225
226 if args.Logger == nil {
227 args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
228 }
229
230 if args.SessionSecret == "" {
231 panic("SESSION SECRET WAS NOT SET. THIS IS REQUIRED. ")
232 }
233
234 e := echo.New()
235
236 e.Pre(middleware.RemoveTrailingSlash())
237 e.Pre(slogecho.New(args.Logger))
238 e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret))))
239 e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
240 AllowOrigins: []string{"*"},
241 AllowHeaders: []string{"*"},
242 AllowMethods: []string{"*"},
243 AllowCredentials: true,
244 MaxAge: 100_000_000,
245 }))
246
247 vdtor := validator.New()
248 vdtor.RegisterValidation("atproto-handle", func(fl validator.FieldLevel) bool {
249 if _, err := syntax.ParseHandle(fl.Field().String()); err != nil {
250 return false
251 }
252 return true
253 })
254 vdtor.RegisterValidation("atproto-did", func(fl validator.FieldLevel) bool {
255 if _, err := syntax.ParseDID(fl.Field().String()); err != nil {
256 return false
257 }
258 return true
259 })
260 vdtor.RegisterValidation("atproto-rkey", func(fl validator.FieldLevel) bool {
261 if _, err := syntax.ParseRecordKey(fl.Field().String()); err != nil {
262 return false
263 }
264 return true
265 })
266 vdtor.RegisterValidation("atproto-nsid", func(fl validator.FieldLevel) bool {
267 if _, err := syntax.ParseNSID(fl.Field().String()); err != nil {
268 return false
269 }
270 return true
271 })
272
273 e.Validator = &CustomValidator{validator: vdtor}
274
275 httpd := &http.Server{
276 Addr: args.Addr,
277 Handler: e,
278 // shitty defaults but okay for now, needed for import repo
279 ReadTimeout: 5 * time.Minute,
280 WriteTimeout: 5 * time.Minute,
281 IdleTimeout: 5 * time.Minute,
282 }
283
284 gdb, err := gorm.Open(sqlite.Open("cocoon.db"), &gorm.Config{})
285 if err != nil {
286 return nil, err
287 }
288 dbw := db.NewDB(gdb)
289
290 rkbytes, err := os.ReadFile(args.RotationKeyPath)
291 if err != nil {
292 return nil, err
293 }
294
295 h := util.RobustHTTPClient()
296
297 plcClient, err := plc.NewClient(&plc.ClientArgs{
298 H: h,
299 Service: "https://plc.directory",
300 PdsHostname: args.Hostname,
301 RotationKey: rkbytes,
302 })
303 if err != nil {
304 return nil, err
305 }
306
307 jwkbytes, err := os.ReadFile(args.JwkPath)
308 if err != nil {
309 return nil, err
310 }
311
312 key, err := helpers.ParseJWKFromBytes(jwkbytes)
313 if err != nil {
314 return nil, err
315 }
316
317 var pkey ecdsa.PrivateKey
318 if err := key.Raw(&pkey); err != nil {
319 return nil, err
320 }
321
322 oauthCli := &http.Client{
323 Timeout: 10 * time.Second,
324 }
325
326 var nonceSecret []byte
327 maybeSecret, err := os.ReadFile("nonce.secret")
328 if err != nil && !os.IsNotExist(err) {
329 args.Logger.Error("error attempting to read nonce secret", "error", err)
330 } else {
331 nonceSecret = maybeSecret
332 }
333
334 s := &Server{
335 http: h,
336 httpd: httpd,
337 echo: e,
338 logger: args.Logger,
339 db: dbw,
340 plcClient: plcClient,
341 privateKey: &pkey,
342 config: &config{
343 Version: args.Version,
344 Did: args.Did,
345 Hostname: args.Hostname,
346 ContactEmail: args.ContactEmail,
347 EnforcePeering: false,
348 Relays: args.Relays,
349 AdminPassword: args.AdminPassword,
350 SmtpName: args.SmtpName,
351 SmtpEmail: args.SmtpEmail,
352 BlockstoreVariant: args.BlockstoreVariant,
353 },
354 evtman: events.NewEventManager(events.NewMemPersister()),
355 passport: identity.NewPassport(h, identity.NewMemCache(10_000)),
356
357 dbName: args.DbName,
358 s3Config: args.S3Config,
359
360 oauthProvider: provider.NewProvider(provider.Args{
361 Hostname: args.Hostname,
362 ClientManagerArgs: client.ManagerArgs{
363 Cli: oauthCli,
364 Logger: args.Logger,
365 },
366 DpopManagerArgs: dpop.ManagerArgs{
367 NonceSecret: nonceSecret,
368 NonceRotationInterval: constants.NonceMaxRotationInterval / 3,
369 OnNonceSecretCreated: func(newNonce []byte) {
370 if err := os.WriteFile("nonce.secret", newNonce, 0644); err != nil {
371 args.Logger.Error("error writing new nonce secret", "error", err)
372 }
373 },
374 Logger: args.Logger,
375 Hostname: args.Hostname,
376 },
377 }),
378 }
379
380 s.loadTemplates()
381
382 s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it
383
384 // TODO: should validate these args
385 if args.SmtpUser == "" || args.SmtpPass == "" || args.SmtpHost == "" || args.SmtpPort == "" || args.SmtpEmail == "" || args.SmtpName == "" {
386 args.Logger.Warn("not enough smpt args were provided. mailing will not work for your server.")
387 } else {
388 mail := mailyak.New(args.SmtpHost+":"+args.SmtpPort, smtp.PlainAuth("", args.SmtpUser, args.SmtpPass, args.SmtpHost))
389 mail.From(s.config.SmtpEmail)
390 mail.FromName(s.config.SmtpName)
391
392 s.mail = mail
393 s.mailLk = &sync.Mutex{}
394 }
395
396 return s, nil
397}
398
399func (s *Server) addRoutes() {
400 // static
401 if s.config.Version == "dev" {
402 s.echo.Static("/static", "server/static")
403 } else {
404 s.echo.GET("/static/*", echo.WrapHandler(http.FileServer(http.FS(staticFS))))
405 }
406
407 // random stuff
408 s.echo.GET("/", s.handleRoot)
409 s.echo.GET("/xrpc/_health", s.handleHealth)
410 s.echo.GET("/.well-known/did.json", s.handleWellKnown)
411 s.echo.GET("/.well-known/oauth-protected-resource", s.handleOauthProtectedResource)
412 s.echo.GET("/.well-known/oauth-authorization-server", s.handleOauthAuthorizationServer)
413 s.echo.GET("/robots.txt", s.handleRobots)
414
415 // public
416 s.echo.GET("/xrpc/com.atproto.identity.resolveHandle", s.handleResolveHandle)
417 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount)
418 s.echo.POST("/xrpc/com.atproto.server.createAccount", s.handleCreateAccount)
419 s.echo.POST("/xrpc/com.atproto.server.createSession", s.handleCreateSession)
420 s.echo.GET("/xrpc/com.atproto.server.describeServer", s.handleDescribeServer)
421
422 s.echo.GET("/xrpc/com.atproto.repo.describeRepo", s.handleDescribeRepo)
423 s.echo.GET("/xrpc/com.atproto.sync.listRepos", s.handleListRepos)
424 s.echo.GET("/xrpc/com.atproto.repo.listRecords", s.handleListRecords)
425 s.echo.GET("/xrpc/com.atproto.repo.getRecord", s.handleRepoGetRecord)
426 s.echo.GET("/xrpc/com.atproto.sync.getRecord", s.handleSyncGetRecord)
427 s.echo.GET("/xrpc/com.atproto.sync.getBlocks", s.handleGetBlocks)
428 s.echo.GET("/xrpc/com.atproto.sync.getLatestCommit", s.handleSyncGetLatestCommit)
429 s.echo.GET("/xrpc/com.atproto.sync.getRepoStatus", s.handleSyncGetRepoStatus)
430 s.echo.GET("/xrpc/com.atproto.sync.getRepo", s.handleSyncGetRepo)
431 s.echo.GET("/xrpc/com.atproto.sync.subscribeRepos", s.handleSyncSubscribeRepos)
432 s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs)
433 s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob)
434
435 // account
436 s.echo.GET("/account", s.handleAccount)
437 s.echo.POST("/account/revoke", s.handleAccountRevoke)
438 s.echo.GET("/account/signin", s.handleAccountSigninGet)
439 s.echo.POST("/account/signin", s.handleAccountSigninPost)
440 s.echo.GET("/account/signout", s.handleAccountSignout)
441
442 // oauth account
443 s.echo.GET("/oauth/jwks", s.handleOauthJwks)
444 s.echo.GET("/oauth/authorize", s.handleOauthAuthorizeGet)
445 s.echo.POST("/oauth/authorize", s.handleOauthAuthorizePost)
446
447 // oauth authorization
448 s.echo.POST("/oauth/par", s.handleOauthPar, s.oauthProvider.BaseMiddleware)
449 s.echo.POST("/oauth/token", s.handleOauthToken, s.oauthProvider.BaseMiddleware)
450
451 // authed
452 s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
453 s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
454 s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
455 s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
456 s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
457 s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
458 s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE
459 s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
460 s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
461 s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
462 s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
463 s.echo.GET("/xrpc/com.atproto.server.checkAccountStatus", s.handleServerCheckAccountStatus, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
464 s.echo.POST("/xrpc/com.atproto.server.deactivateAccount", s.handleServerDeactivateAccount, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
465 s.echo.POST("/xrpc/com.atproto.server.activateAccount", s.handleServerActivateAccount, 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) UpdateRepo(ctx context.Context, did string, root cid.Cid, rev string) error {
649 if err := s.db.Exec("UPDATE repos SET root = ?, rev = ? WHERE did = ?", nil, root.Bytes(), rev, did).Error; err != nil {
650 return err
651 }
652
653 return nil
654}