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