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