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