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