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