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