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