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