forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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—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}