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