this repo has no description
1package state 2 3import ( 4 "context" 5 "fmt" 6 "log" 7 "log/slog" 8 "net/http" 9 "strings" 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 "github.com/bluesky-social/indigo/atproto/syntax" 14 lexutil "github.com/bluesky-social/indigo/lex/util" 15 securejoin "github.com/cyphar/filepath-securejoin" 16 "github.com/go-chi/chi/v5" 17 "github.com/posthog/posthog-go" 18 "tangled.sh/tangled.sh/core/api/tangled" 19 "tangled.sh/tangled.sh/core/appview" 20 "tangled.sh/tangled.sh/core/appview/cache" 21 "tangled.sh/tangled.sh/core/appview/cache/session" 22 "tangled.sh/tangled.sh/core/appview/config" 23 "tangled.sh/tangled.sh/core/appview/db" 24 "tangled.sh/tangled.sh/core/appview/idresolver" 25 "tangled.sh/tangled.sh/core/appview/notify" 26 "tangled.sh/tangled.sh/core/appview/oauth" 27 "tangled.sh/tangled.sh/core/appview/pages" 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 29 "tangled.sh/tangled.sh/core/eventconsumer" 30 "tangled.sh/tangled.sh/core/jetstream" 31 "tangled.sh/tangled.sh/core/knotclient" 32 tlog "tangled.sh/tangled.sh/core/log" 33 "tangled.sh/tangled.sh/core/rbac" 34) 35 36type State struct { 37 db *db.DB 38 notifier notify.Notifier 39 oauth *oauth.OAuth 40 enforcer *rbac.Enforcer 41 tidClock syntax.TIDClock 42 pages *pages.Pages 43 sess *session.SessionStore 44 idResolver *idresolver.Resolver 45 posthog posthog.Client 46 jc *jetstream.JetstreamClient 47 config *config.Config 48 repoResolver *reporesolver.RepoResolver 49 knotstream *eventconsumer.Consumer 50 spindlestream *eventconsumer.Consumer 51} 52 53func Make(ctx context.Context, config *config.Config) (*State, error) { 54 d, err := db.Make(config.Core.DbPath) 55 if err != nil { 56 return nil, fmt.Errorf("failed to create db: %w", err) 57 } 58 59 enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 60 if err != nil { 61 return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 } 63 64 clock := syntax.NewTIDClock(0) 65 66 pgs := pages.NewPages(config) 67 68 res, err := idresolver.RedisResolver(config.Redis) 69 if err != nil { 70 log.Printf("failed to create redis resolver: %v", err) 71 res = idresolver.DefaultResolver() 72 } 73 74 cache := cache.New(config.Redis.Addr) 75 sess := session.New(cache) 76 77 oauth := oauth.NewOAuth(config, sess) 78 79 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 80 if err != nil { 81 return nil, fmt.Errorf("failed to create posthog client: %w", err) 82 } 83 84 repoResolver := reporesolver.New(config, enforcer, res, d) 85 86 wrapper := db.DbWrapper{d} 87 jc, err := jetstream.NewJetstreamClient( 88 config.Jetstream.Endpoint, 89 "appview", 90 []string{ 91 tangled.GraphFollowNSID, 92 tangled.FeedStarNSID, 93 tangled.PublicKeyNSID, 94 tangled.RepoArtifactNSID, 95 tangled.ActorProfileNSID, 96 tangled.SpindleMemberNSID, 97 tangled.SpindleNSID, 98 }, 99 nil, 100 slog.Default(), 101 wrapper, 102 false, 103 104 // in-memory filter is inapplicalble to appview so 105 // we'll never log dids anyway. 106 false, 107 ) 108 if err != nil { 109 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 110 } 111 112 ingester := appview.Ingester{ 113 Db: wrapper, 114 Enforcer: enforcer, 115 IdResolver: res, 116 Config: config, 117 Logger: tlog.New("ingester"), 118 } 119 err = jc.StartJetstream(ctx, ingester.Ingest()) 120 if err != nil { 121 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 122 } 123 124 knotstream, err := Knotstream(ctx, config, d, enforcer, posthog) 125 if err != nil { 126 return nil, fmt.Errorf("failed to start knotstream consumer: %w", err) 127 } 128 knotstream.Start(ctx) 129 130 spindlestream, err := Spindlestream(ctx, config, d, enforcer) 131 if err != nil { 132 return nil, fmt.Errorf("failed to start spindlestream consumer: %w", err) 133 } 134 spindlestream.Start(ctx) 135 136 notifier := notify.NewMergedNotifier( 137 ) 138 139 state := &State{ 140 d, 141 notifier, 142 oauth, 143 enforcer, 144 clock, 145 pgs, 146 sess, 147 res, 148 posthog, 149 jc, 150 config, 151 repoResolver, 152 knotstream, 153 spindlestream, 154 } 155 156 return state, nil 157} 158 159func TID(c *syntax.TIDClock) string { 160 return c.Next().String() 161} 162 163func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 164 user := s.oauth.GetUser(r) 165 166 timeline, err := db.MakeTimeline(s.db) 167 if err != nil { 168 log.Println(err) 169 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 170 } 171 172 var didsToResolve []string 173 for _, ev := range timeline { 174 if ev.Repo != nil { 175 didsToResolve = append(didsToResolve, ev.Repo.Did) 176 if ev.Source != nil { 177 didsToResolve = append(didsToResolve, ev.Source.Did) 178 } 179 } 180 if ev.Follow != nil { 181 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 182 } 183 if ev.Star != nil { 184 didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 185 } 186 } 187 188 resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 189 didHandleMap := make(map[string]string) 190 for _, identity := range resolvedIds { 191 if !identity.Handle.IsInvalidHandle() { 192 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 193 } else { 194 didHandleMap[identity.DID.String()] = identity.DID.String() 195 } 196 } 197 198 s.pages.Timeline(w, pages.TimelineParams{ 199 LoggedInUser: user, 200 Timeline: timeline, 201 DidHandleMap: didHandleMap, 202 }) 203 204 return 205} 206 207// requires auth 208// func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { 209// switch r.Method { 210// case http.MethodGet: 211// // list open registrations under this did 212// 213// return 214// case http.MethodPost: 215// session, err := s.oauth.Stores().Get(r, oauth.SessionName) 216// if err != nil || session.IsNew { 217// log.Println("unauthorized attempt to generate registration key") 218// http.Error(w, "Forbidden", http.StatusUnauthorized) 219// return 220// } 221// 222// did := session.Values[oauth.SessionDid].(string) 223// 224// // check if domain is valid url, and strip extra bits down to just host 225// domain := r.FormValue("domain") 226// if domain == "" { 227// http.Error(w, "Invalid form", http.StatusBadRequest) 228// return 229// } 230// 231// key, err := db.GenerateRegistrationKey(s.db, domain, did) 232// 233// if err != nil { 234// log.Println(err) 235// http.Error(w, "unable to register this domain", http.StatusNotAcceptable) 236// return 237// } 238// 239// w.Write([]byte(key)) 240// } 241// } 242 243func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 244 user := chi.URLParam(r, "user") 245 user = strings.TrimPrefix(user, "@") 246 247 if user == "" { 248 w.WriteHeader(http.StatusBadRequest) 249 return 250 } 251 252 id, err := s.idResolver.ResolveIdent(r.Context(), user) 253 if err != nil { 254 w.WriteHeader(http.StatusInternalServerError) 255 return 256 } 257 258 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 259 if err != nil { 260 w.WriteHeader(http.StatusNotFound) 261 return 262 } 263 264 if len(pubKeys) == 0 { 265 w.WriteHeader(http.StatusNotFound) 266 return 267 } 268 269 for _, k := range pubKeys { 270 key := strings.TrimRight(k.Key, "\n") 271 w.Write([]byte(fmt.Sprintln(key))) 272 } 273} 274 275// create a signed request and check if a node responds to that 276// func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 277// user := s.oauth.GetUser(r) 278// 279// noticeId := "operation-error" 280// defaultErr := "Failed to register spindle. Try again later." 281// fail := func() { 282// s.pages.Notice(w, noticeId, defaultErr) 283// } 284// 285// domain := chi.URLParam(r, "domain") 286// if domain == "" { 287// http.Error(w, "malformed url", http.StatusBadRequest) 288// return 289// } 290// log.Println("checking ", domain) 291// 292// secret, err := db.GetRegistrationKey(s.db, domain) 293// if err != nil { 294// log.Printf("no key found for domain %s: %s\n", domain, err) 295// return 296// } 297// 298// client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 299// if err != nil { 300// log.Println("failed to create client to ", domain) 301// } 302// 303// resp, err := client.Init(user.Did) 304// if err != nil { 305// w.Write([]byte("no dice")) 306// log.Println("domain was unreachable after 5 seconds") 307// return 308// } 309// 310// if resp.StatusCode == http.StatusConflict { 311// log.Println("status conflict", resp.StatusCode) 312// w.Write([]byte("already registered, sorry!")) 313// return 314// } 315// 316// if resp.StatusCode != http.StatusNoContent { 317// log.Println("status nok", resp.StatusCode) 318// w.Write([]byte("no dice")) 319// return 320// } 321// 322// // verify response mac 323// signature := resp.Header.Get("X-Signature") 324// signatureBytes, err := hex.DecodeString(signature) 325// if err != nil { 326// return 327// } 328// 329// expectedMac := hmac.New(sha256.New, []byte(secret)) 330// expectedMac.Write([]byte("ok")) 331// 332// if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 333// log.Printf("response body signature mismatch: %x\n", signatureBytes) 334// return 335// } 336// 337// tx, err := s.db.BeginTx(r.Context(), nil) 338// if err != nil { 339// log.Println("failed to start tx", err) 340// http.Error(w, err.Error(), http.StatusInternalServerError) 341// return 342// } 343// defer func() { 344// tx.Rollback() 345// err = s.enforcer.E.LoadPolicy() 346// if err != nil { 347// log.Println("failed to rollback policies") 348// } 349// }() 350// 351// // mark as registered 352// err = db.Register(tx, domain) 353// if err != nil { 354// log.Println("failed to register domain", err) 355// http.Error(w, err.Error(), http.StatusInternalServerError) 356// return 357// } 358// 359// // set permissions for this did as owner 360// reg, err := db.RegistrationByDomain(tx, domain) 361// if err != nil { 362// log.Println("failed to register domain", err) 363// http.Error(w, err.Error(), http.StatusInternalServerError) 364// return 365// } 366// 367// // add basic acls for this domain 368// err = s.enforcer.AddKnot(domain) 369// if err != nil { 370// log.Println("failed to setup owner of domain", err) 371// http.Error(w, err.Error(), http.StatusInternalServerError) 372// return 373// } 374// 375// // add this did as owner of this domain 376// err = s.enforcer.AddKnotOwner(domain, reg.ByDid) 377// if err != nil { 378// log.Println("failed to setup owner of domain", err) 379// http.Error(w, err.Error(), http.StatusInternalServerError) 380// return 381// } 382// 383// err = tx.Commit() 384// if err != nil { 385// log.Println("failed to commit changes", err) 386// http.Error(w, err.Error(), http.StatusInternalServerError) 387// return 388// } 389// 390// err = s.enforcer.E.SavePolicy() 391// if err != nil { 392// log.Println("failed to update ACLs", err) 393// http.Error(w, err.Error(), http.StatusInternalServerError) 394// return 395// } 396// 397// // add this knot to knotstream 398// go s.knotstream.AddSource( 399// context.Background(), 400// eventconsumer.NewKnotSource(domain), 401// ) 402// 403// w.Write([]byte("check success")) 404// } 405 406// func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) { 407// domain := chi.URLParam(r, "domain") 408// if domain == "" { 409// http.Error(w, "malformed url", http.StatusBadRequest) 410// return 411// } 412// 413// user := s.oauth.GetUser(r) 414// reg, err := db.RegistrationByDomain(s.db, domain) 415// if err != nil { 416// w.Write([]byte("failed to pull up registration info")) 417// return 418// } 419// 420// var members []string 421// if reg.Registered != nil { 422// members, err = s.enforcer.GetUserByRole("server:member", domain) 423// if err != nil { 424// w.Write([]byte("failed to fetch member list")) 425// return 426// } 427// } 428// 429// var didsToResolve []string 430// for _, m := range members { 431// didsToResolve = append(didsToResolve, m) 432// } 433// didsToResolve = append(didsToResolve, reg.ByDid) 434// resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 435// didHandleMap := make(map[string]string) 436// for _, identity := range resolvedIds { 437// if !identity.Handle.IsInvalidHandle() { 438// didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 439// } else { 440// didHandleMap[identity.DID.String()] = identity.DID.String() 441// } 442// } 443// 444// ok, err := s.enforcer.IsKnotOwner(user.Did, domain) 445// isOwner := err == nil && ok 446// 447// p := pages.KnotParams{ 448// LoggedInUser: user, 449// DidHandleMap: didHandleMap, 450// Registration: reg, 451// Members: members, 452// IsOwner: isOwner, 453// } 454// 455// s.pages.Knot(w, p) 456// } 457 458// get knots registered by this user 459// func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 460// // for now, this is just pubkeys 461// user := s.oauth.GetUser(r) 462// registrations, err := db.RegistrationsByDid(s.db, user.Did) 463// if err != nil { 464// log.Println(err) 465// } 466// 467// s.pages.Knots(w, pages.KnotsParams{ 468// LoggedInUser: user, 469// Registrations: registrations, 470// }) 471// } 472 473// list members of domain, requires auth and requires owner status 474// func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) { 475// domain := chi.URLParam(r, "domain") 476// if domain == "" { 477// http.Error(w, "malformed url", http.StatusBadRequest) 478// return 479// } 480// 481// // list all members for this domain 482// memberDids, err := s.enforcer.GetUserByRole("server:member", domain) 483// if err != nil { 484// w.Write([]byte("failed to fetch member list")) 485// return 486// } 487// 488// w.Write([]byte(strings.Join(memberDids, "\n"))) 489// return 490// } 491 492// add member to domain, requires auth and requires invite access 493// func (s *State) AddMember(w http.ResponseWriter, r *http.Request) { 494// domain := chi.URLParam(r, "domain") 495// if domain == "" { 496// http.Error(w, "malformed url", http.StatusBadRequest) 497// return 498// } 499// 500// subjectIdentifier := r.FormValue("subject") 501// if subjectIdentifier == "" { 502// http.Error(w, "malformed form", http.StatusBadRequest) 503// return 504// } 505// 506// subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier) 507// if err != nil { 508// w.Write([]byte("failed to resolve member did to a handle")) 509// return 510// } 511// log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain) 512// 513// // announce this relation into the firehose, store into owners' pds 514// client, err := s.oauth.AuthorizedClient(r) 515// if err != nil { 516// http.Error(w, "failed to authorize client", http.StatusInternalServerError) 517// return 518// } 519// currentUser := s.oauth.GetUser(r) 520// createdAt := time.Now().Format(time.RFC3339) 521// resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 522// Collection: tangled.KnotMemberNSID, 523// Repo: currentUser.Did, 524// Rkey: appview.TID(), 525// Record: &lexutil.LexiconTypeDecoder{ 526// Val: &tangled.KnotMember{ 527// Subject: subjectIdentity.DID.String(), 528// Domain: domain, 529// CreatedAt: createdAt, 530// }}, 531// }) 532// 533// // invalid record 534// if err != nil { 535// log.Printf("failed to create record: %s", err) 536// return 537// } 538// log.Println("created atproto record: ", resp.Uri) 539// 540// secret, err := db.GetRegistrationKey(s.db, domain) 541// if err != nil { 542// log.Printf("no key found for domain %s: %s\n", domain, err) 543// return 544// } 545// 546// ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 547// if err != nil { 548// log.Println("failed to create client to ", domain) 549// return 550// } 551// 552// ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 553// if err != nil { 554// log.Printf("failed to make request to %s: %s", domain, err) 555// return 556// } 557// 558// if ksResp.StatusCode != http.StatusNoContent { 559// w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err))) 560// return 561// } 562// 563// err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 564// if err != nil { 565// w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 566// return 567// } 568// 569// w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String()))) 570// } 571 572// func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 573// } 574 575func validateRepoName(name string) error { 576 // check for path traversal attempts 577 if name == "." || name == ".." || 578 strings.Contains(name, "/") || strings.Contains(name, "\\") { 579 return fmt.Errorf("Repository name contains invalid path characters") 580 } 581 582 // check for sequences that could be used for traversal when normalized 583 if strings.Contains(name, "./") || strings.Contains(name, "../") || 584 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 585 return fmt.Errorf("Repository name contains invalid path sequence") 586 } 587 588 // then continue with character validation 589 for _, char := range name { 590 if !((char >= 'a' && char <= 'z') || 591 (char >= 'A' && char <= 'Z') || 592 (char >= '0' && char <= '9') || 593 char == '-' || char == '_' || char == '.') { 594 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 595 } 596 } 597 598 // additional check to prevent multiple sequential dots 599 if strings.Contains(name, "..") { 600 return fmt.Errorf("Repository name cannot contain sequential dots") 601 } 602 603 // if all checks pass 604 return nil 605} 606 607func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 608 switch r.Method { 609 case http.MethodGet: 610 user := s.oauth.GetUser(r) 611 knots, err := s.enforcer.GetKnotsForUser(user.Did) 612 if err != nil { 613 s.pages.Notice(w, "repo", "Invalid user account.") 614 return 615 } 616 617 s.pages.NewRepo(w, pages.NewRepoParams{ 618 LoggedInUser: user, 619 Knots: knots, 620 }) 621 622 case http.MethodPost: 623 user := s.oauth.GetUser(r) 624 625 domain := r.FormValue("domain") 626 if domain == "" { 627 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 628 return 629 } 630 631 repoName := r.FormValue("name") 632 if repoName == "" { 633 s.pages.Notice(w, "repo", "Repository name cannot be empty.") 634 return 635 } 636 637 if err := validateRepoName(repoName); err != nil { 638 s.pages.Notice(w, "repo", err.Error()) 639 return 640 } 641 642 defaultBranch := r.FormValue("branch") 643 if defaultBranch == "" { 644 defaultBranch = "main" 645 } 646 647 description := r.FormValue("description") 648 649 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 650 if err != nil || !ok { 651 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 652 return 653 } 654 655 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 656 if err == nil && existingRepo != nil { 657 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 658 return 659 } 660 661 secret, err := db.GetRegistrationKey(s.db, domain) 662 if err != nil { 663 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 664 return 665 } 666 667 client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 668 if err != nil { 669 s.pages.Notice(w, "repo", "Failed to connect to knot server.") 670 return 671 } 672 673 rkey := appview.TID() 674 repo := &db.Repo{ 675 Did: user.Did, 676 Name: repoName, 677 Knot: domain, 678 Rkey: rkey, 679 Description: description, 680 } 681 682 xrpcClient, err := s.oauth.AuthorizedClient(r) 683 if err != nil { 684 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 685 return 686 } 687 688 createdAt := time.Now().Format(time.RFC3339) 689 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 690 Collection: tangled.RepoNSID, 691 Repo: user.Did, 692 Rkey: rkey, 693 Record: &lexutil.LexiconTypeDecoder{ 694 Val: &tangled.Repo{ 695 Knot: repo.Knot, 696 Name: repoName, 697 CreatedAt: createdAt, 698 Owner: user.Did, 699 }}, 700 }) 701 if err != nil { 702 log.Printf("failed to create record: %s", err) 703 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 704 return 705 } 706 log.Println("created repo record: ", atresp.Uri) 707 708 tx, err := s.db.BeginTx(r.Context(), nil) 709 if err != nil { 710 log.Println(err) 711 s.pages.Notice(w, "repo", "Failed to save repository information.") 712 return 713 } 714 defer func() { 715 tx.Rollback() 716 err = s.enforcer.E.LoadPolicy() 717 if err != nil { 718 log.Println("failed to rollback policies") 719 } 720 }() 721 722 resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 723 if err != nil { 724 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 725 return 726 } 727 728 switch resp.StatusCode { 729 case http.StatusConflict: 730 s.pages.Notice(w, "repo", "A repository with that name already exists.") 731 return 732 case http.StatusInternalServerError: 733 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 734 case http.StatusNoContent: 735 // continue 736 } 737 738 repo.AtUri = atresp.Uri 739 err = db.AddRepo(tx, repo) 740 if err != nil { 741 log.Println(err) 742 s.pages.Notice(w, "repo", "Failed to save repository information.") 743 return 744 } 745 746 // acls 747 p, _ := securejoin.SecureJoin(user.Did, repoName) 748 err = s.enforcer.AddRepo(user.Did, domain, p) 749 if err != nil { 750 log.Println(err) 751 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 752 return 753 } 754 755 err = tx.Commit() 756 if err != nil { 757 log.Println("failed to commit changes", err) 758 http.Error(w, err.Error(), http.StatusInternalServerError) 759 return 760 } 761 762 err = s.enforcer.E.SavePolicy() 763 if err != nil { 764 log.Println("failed to update ACLs", err) 765 http.Error(w, err.Error(), http.StatusInternalServerError) 766 return 767 } 768 769 if !s.config.Core.Dev { 770 err = s.posthog.Enqueue(posthog.Capture{ 771 DistinctId: user.Did, 772 Event: "new_repo", 773 Properties: posthog.Properties{"repo": repoName, "repo_at": repo.AtUri}, 774 }) 775 if err != nil { 776 log.Println("failed to enqueue posthog event:", err) 777 } 778 } 779 780 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 781 return 782 } 783}