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&mdash;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}