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—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}