forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package state 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io" 10 "log" 11 mathrand "math/rand/v2" 12 "net/http" 13 "path" 14 "slices" 15 "strconv" 16 "strings" 17 "time" 18 19 "tangled.sh/tangled.sh/core/api/tangled" 20 "tangled.sh/tangled.sh/core/appview" 21 "tangled.sh/tangled.sh/core/appview/db" 22 "tangled.sh/tangled.sh/core/appview/knotclient" 23 "tangled.sh/tangled.sh/core/appview/oauth" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/appview/pages/markup" 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 27 "tangled.sh/tangled.sh/core/appview/pagination" 28 "tangled.sh/tangled.sh/core/types" 29 30 "github.com/bluesky-social/indigo/atproto/data" 31 "github.com/bluesky-social/indigo/atproto/identity" 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 securejoin "github.com/cyphar/filepath-securejoin" 34 "github.com/go-chi/chi/v5" 35 "github.com/go-git/go-git/v5/plumbing" 36 "github.com/posthog/posthog-go" 37 38 comatproto "github.com/bluesky-social/indigo/api/atproto" 39 lexutil "github.com/bluesky-social/indigo/lex/util" 40) 41 42func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 43 ref := chi.URLParam(r, "ref") 44 f, err := s.fullyResolvedRepo(r) 45 if err != nil { 46 log.Println("failed to fully resolve repo", err) 47 return 48 } 49 50 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 51 if err != nil { 52 log.Printf("failed to create unsigned client for %s", f.Knot) 53 s.pages.Error503(w) 54 return 55 } 56 57 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref) 58 if err != nil { 59 s.pages.Error503(w) 60 log.Println("failed to reach knotserver", err) 61 return 62 } 63 defer resp.Body.Close() 64 65 body, err := io.ReadAll(resp.Body) 66 if err != nil { 67 log.Printf("Error reading response body: %v", err) 68 return 69 } 70 71 var result types.RepoIndexResponse 72 err = json.Unmarshal(body, &result) 73 if err != nil { 74 log.Printf("Error unmarshalling response body: %v", err) 75 return 76 } 77 78 tagMap := make(map[string][]string) 79 for _, tag := range result.Tags { 80 hash := tag.Hash 81 if tag.Tag != nil { 82 hash = tag.Tag.Target.String() 83 } 84 tagMap[hash] = append(tagMap[hash], tag.Name) 85 } 86 87 for _, branch := range result.Branches { 88 hash := branch.Hash 89 tagMap[hash] = append(tagMap[hash], branch.Name) 90 } 91 92 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 93 if a.Name == result.Ref { 94 return -1 95 } 96 if a.IsDefault { 97 return -1 98 } 99 if b.IsDefault { 100 return 1 101 } 102 if a.Commit != nil { 103 if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 104 return 1 105 } else { 106 return -1 107 } 108 } 109 return strings.Compare(a.Name, b.Name) * -1 110 }) 111 112 commitCount := len(result.Commits) 113 branchCount := len(result.Branches) 114 tagCount := len(result.Tags) 115 fileCount := len(result.Files) 116 117 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) 118 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] 119 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] 120 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))] 121 122 emails := uniqueEmails(commitsTrunc) 123 124 user := s.oauth.GetUser(r) 125 repoInfo := f.RepoInfo(s, user) 126 127 secret, err := db.GetRegistrationKey(s.db, f.Knot) 128 if err != nil { 129 log.Printf("failed to get registration key for %s: %s", f.Knot, err) 130 s.pages.Error500(w) 131 return 132 } 133 134 signedClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 135 if err != nil { 136 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 137 s.pages.Error500(w) 138 return 139 } 140 141 var forkInfo *types.ForkInfo 142 if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 143 forkInfo, err = getForkInfo(repoInfo, s, f, w, user, signedClient) 144 if err != nil { 145 log.Printf("failed to fetch fork information: %v", err) 146 s.pages.Error500(w) 147 return 148 } 149 } 150 151 repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoAt.String(), repoInfo.Name, f.Ref) 152 if err != nil { 153 // non-fatal 154 log.Printf("failed to compute language percentages: %s", err) 155 } 156 157 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 158 LoggedInUser: user, 159 RepoInfo: repoInfo, 160 TagMap: tagMap, 161 RepoIndexResponse: result, 162 CommitsTrunc: commitsTrunc, 163 TagsTrunc: tagsTrunc, 164 ForkInfo: forkInfo, 165 BranchesTrunc: branchesTrunc, 166 EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 167 LanguagePercentages: repoLanguages.Languages, 168 }) 169 return 170} 171 172func getForkInfo( 173 repoInfo repoinfo.RepoInfo, 174 s *State, 175 f *FullyResolvedRepo, 176 w http.ResponseWriter, 177 user *oauth.User, 178 signedClient *knotclient.SignedClient, 179) (*types.ForkInfo, error) { 180 if user == nil { 181 return nil, nil 182 } 183 184 forkInfo := types.ForkInfo{ 185 IsFork: repoInfo.Source != nil, 186 Status: types.UpToDate, 187 } 188 189 if !forkInfo.IsFork { 190 forkInfo.IsFork = false 191 return &forkInfo, nil 192 } 193 194 us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, s.config.Core.Dev) 195 if err != nil { 196 log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 197 return nil, err 198 } 199 200 resp, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 201 if err != nil { 202 log.Println("failed to reach knotserver", err) 203 return nil, err 204 } 205 206 body, err := io.ReadAll(resp.Body) 207 if err != nil { 208 log.Printf("Error reading forkResponse forkBody: %v", err) 209 return nil, err 210 } 211 212 var result types.RepoBranchesResponse 213 err = json.Unmarshal(body, &result) 214 if err != nil { 215 log.Println("failed to parse forkResponse:", err) 216 return nil, err 217 } 218 219 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 220 return branch.Name == f.Ref 221 }) { 222 forkInfo.Status = types.MissingBranch 223 return &forkInfo, nil 224 } 225 226 newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 227 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 228 log.Printf("failed to update tracking branch: %s", err) 229 return nil, err 230 } 231 232 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 233 234 var status types.AncestorCheckResponse 235 forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 236 if err != nil { 237 log.Printf("failed to check if fork is ahead/behind: %s", err) 238 return nil, err 239 } 240 241 if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 242 log.Printf("failed to decode fork status: %s", err) 243 return nil, err 244 } 245 246 forkInfo.Status = status.Status 247 return &forkInfo, nil 248} 249 250func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 251 f, err := s.fullyResolvedRepo(r) 252 if err != nil { 253 log.Println("failed to fully resolve repo", err) 254 return 255 } 256 257 page := 1 258 if r.URL.Query().Get("page") != "" { 259 page, err = strconv.Atoi(r.URL.Query().Get("page")) 260 if err != nil { 261 page = 1 262 } 263 } 264 265 ref := chi.URLParam(r, "ref") 266 267 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 268 if err != nil { 269 log.Println("failed to create unsigned client", err) 270 return 271 } 272 273 resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 274 if err != nil { 275 log.Println("failed to reach knotserver", err) 276 return 277 } 278 279 body, err := io.ReadAll(resp.Body) 280 if err != nil { 281 log.Printf("error reading response body: %v", err) 282 return 283 } 284 285 var repolog types.RepoLogResponse 286 err = json.Unmarshal(body, &repolog) 287 if err != nil { 288 log.Println("failed to parse json response", err) 289 return 290 } 291 292 result, err := us.Tags(f.OwnerDid(), f.RepoName) 293 if err != nil { 294 log.Println("failed to reach knotserver", err) 295 return 296 } 297 298 tagMap := make(map[string][]string) 299 for _, tag := range result.Tags { 300 hash := tag.Hash 301 if tag.Tag != nil { 302 hash = tag.Tag.Target.String() 303 } 304 tagMap[hash] = append(tagMap[hash], tag.Name) 305 } 306 307 user := s.oauth.GetUser(r) 308 s.pages.RepoLog(w, pages.RepoLogParams{ 309 LoggedInUser: user, 310 TagMap: tagMap, 311 RepoInfo: f.RepoInfo(s, user), 312 RepoLogResponse: repolog, 313 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), 314 }) 315 return 316} 317 318func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 319 f, err := s.fullyResolvedRepo(r) 320 if err != nil { 321 log.Println("failed to get repo and knot", err) 322 w.WriteHeader(http.StatusBadRequest) 323 return 324 } 325 326 user := s.oauth.GetUser(r) 327 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 328 RepoInfo: f.RepoInfo(s, user), 329 }) 330 return 331} 332 333func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 334 f, err := s.fullyResolvedRepo(r) 335 if err != nil { 336 log.Println("failed to get repo and knot", err) 337 w.WriteHeader(http.StatusBadRequest) 338 return 339 } 340 341 repoAt := f.RepoAt 342 rkey := repoAt.RecordKey().String() 343 if rkey == "" { 344 log.Println("invalid aturi for repo", err) 345 w.WriteHeader(http.StatusInternalServerError) 346 return 347 } 348 349 user := s.oauth.GetUser(r) 350 351 switch r.Method { 352 case http.MethodGet: 353 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 354 RepoInfo: f.RepoInfo(s, user), 355 }) 356 return 357 case http.MethodPut: 358 user := s.oauth.GetUser(r) 359 newDescription := r.FormValue("description") 360 client, err := s.oauth.AuthorizedClient(r) 361 if err != nil { 362 log.Println("failed to get client") 363 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 364 return 365 } 366 367 // optimistic update 368 err = db.UpdateDescription(s.db, string(repoAt), newDescription) 369 if err != nil { 370 log.Println("failed to perferom update-description query", err) 371 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 372 return 373 } 374 375 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 376 // 377 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 378 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 379 if err != nil { 380 // failed to get record 381 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 382 return 383 } 384 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 385 Collection: tangled.RepoNSID, 386 Repo: user.Did, 387 Rkey: rkey, 388 SwapRecord: ex.Cid, 389 Record: &lexutil.LexiconTypeDecoder{ 390 Val: &tangled.Repo{ 391 Knot: f.Knot, 392 Name: f.RepoName, 393 Owner: user.Did, 394 CreatedAt: f.CreatedAt, 395 Description: &newDescription, 396 }, 397 }, 398 }) 399 400 if err != nil { 401 log.Println("failed to perferom update-description query", err) 402 // failed to get record 403 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 404 return 405 } 406 407 newRepoInfo := f.RepoInfo(s, user) 408 newRepoInfo.Description = newDescription 409 410 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 411 RepoInfo: newRepoInfo, 412 }) 413 return 414 } 415} 416 417func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 418 f, err := s.fullyResolvedRepo(r) 419 if err != nil { 420 log.Println("failed to fully resolve repo", err) 421 return 422 } 423 ref := chi.URLParam(r, "ref") 424 protocol := "http" 425 if !s.config.Core.Dev { 426 protocol = "https" 427 } 428 429 if !plumbing.IsHash(ref) { 430 s.pages.Error404(w) 431 return 432 } 433 434 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 435 if err != nil { 436 log.Println("failed to reach knotserver", err) 437 return 438 } 439 440 body, err := io.ReadAll(resp.Body) 441 if err != nil { 442 log.Printf("Error reading response body: %v", err) 443 return 444 } 445 446 var result types.RepoCommitResponse 447 err = json.Unmarshal(body, &result) 448 if err != nil { 449 log.Println("failed to parse response:", err) 450 return 451 } 452 453 user := s.oauth.GetUser(r) 454 s.pages.RepoCommit(w, pages.RepoCommitParams{ 455 LoggedInUser: user, 456 RepoInfo: f.RepoInfo(s, user), 457 RepoCommitResponse: result, 458 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), 459 }) 460 return 461} 462 463func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 464 f, err := s.fullyResolvedRepo(r) 465 if err != nil { 466 log.Println("failed to fully resolve repo", err) 467 return 468 } 469 470 ref := chi.URLParam(r, "ref") 471 treePath := chi.URLParam(r, "*") 472 protocol := "http" 473 if !s.config.Core.Dev { 474 protocol = "https" 475 } 476 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 477 if err != nil { 478 log.Println("failed to reach knotserver", err) 479 return 480 } 481 482 body, err := io.ReadAll(resp.Body) 483 if err != nil { 484 log.Printf("Error reading response body: %v", err) 485 return 486 } 487 488 var result types.RepoTreeResponse 489 err = json.Unmarshal(body, &result) 490 if err != nil { 491 log.Println("failed to parse response:", err) 492 return 493 } 494 495 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 496 // so we can safely redirect to the "parent" (which is the same file). 497 if len(result.Files) == 0 && result.Parent == treePath { 498 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 499 return 500 } 501 502 user := s.oauth.GetUser(r) 503 504 var breadcrumbs [][]string 505 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 506 if treePath != "" { 507 for idx, elem := range strings.Split(treePath, "/") { 508 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 509 } 510 } 511 512 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 513 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 514 515 s.pages.RepoTree(w, pages.RepoTreeParams{ 516 LoggedInUser: user, 517 BreadCrumbs: breadcrumbs, 518 BaseTreeLink: baseTreeLink, 519 BaseBlobLink: baseBlobLink, 520 RepoInfo: f.RepoInfo(s, user), 521 RepoTreeResponse: result, 522 }) 523 return 524} 525 526func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 527 f, err := s.fullyResolvedRepo(r) 528 if err != nil { 529 log.Println("failed to get repo and knot", err) 530 return 531 } 532 533 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 534 if err != nil { 535 log.Println("failed to create unsigned client", err) 536 return 537 } 538 539 result, err := us.Tags(f.OwnerDid(), f.RepoName) 540 if err != nil { 541 log.Println("failed to reach knotserver", err) 542 return 543 } 544 545 artifacts, err := db.GetArtifact(s.db, db.FilterEq("repo_at", f.RepoAt)) 546 if err != nil { 547 log.Println("failed grab artifacts", err) 548 return 549 } 550 551 // convert artifacts to map for easy UI building 552 artifactMap := make(map[plumbing.Hash][]db.Artifact) 553 for _, a := range artifacts { 554 artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 555 } 556 557 var danglingArtifacts []db.Artifact 558 for _, a := range artifacts { 559 found := false 560 for _, t := range result.Tags { 561 if t.Tag != nil { 562 if t.Tag.Hash == a.Tag { 563 found = true 564 } 565 } 566 } 567 568 if !found { 569 danglingArtifacts = append(danglingArtifacts, a) 570 } 571 } 572 573 user := s.oauth.GetUser(r) 574 s.pages.RepoTags(w, pages.RepoTagsParams{ 575 LoggedInUser: user, 576 RepoInfo: f.RepoInfo(s, user), 577 RepoTagsResponse: *result, 578 ArtifactMap: artifactMap, 579 DanglingArtifacts: danglingArtifacts, 580 }) 581 return 582} 583 584func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 585 f, err := s.fullyResolvedRepo(r) 586 if err != nil { 587 log.Println("failed to get repo and knot", err) 588 return 589 } 590 591 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 592 if err != nil { 593 log.Println("failed to create unsigned client", err) 594 return 595 } 596 597 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 598 if err != nil { 599 log.Println("failed to reach knotserver", err) 600 return 601 } 602 603 body, err := io.ReadAll(resp.Body) 604 if err != nil { 605 log.Printf("Error reading response body: %v", err) 606 return 607 } 608 609 var result types.RepoBranchesResponse 610 err = json.Unmarshal(body, &result) 611 if err != nil { 612 log.Println("failed to parse response:", err) 613 return 614 } 615 616 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 617 if a.IsDefault { 618 return -1 619 } 620 if b.IsDefault { 621 return 1 622 } 623 if a.Commit != nil { 624 if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 625 return 1 626 } else { 627 return -1 628 } 629 } 630 return strings.Compare(a.Name, b.Name) * -1 631 }) 632 633 user := s.oauth.GetUser(r) 634 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 635 LoggedInUser: user, 636 RepoInfo: f.RepoInfo(s, user), 637 RepoBranchesResponse: result, 638 }) 639 return 640} 641 642func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 643 f, err := s.fullyResolvedRepo(r) 644 if err != nil { 645 log.Println("failed to get repo and knot", err) 646 return 647 } 648 649 ref := chi.URLParam(r, "ref") 650 filePath := chi.URLParam(r, "*") 651 protocol := "http" 652 if !s.config.Core.Dev { 653 protocol = "https" 654 } 655 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 656 if err != nil { 657 log.Println("failed to reach knotserver", err) 658 return 659 } 660 661 body, err := io.ReadAll(resp.Body) 662 if err != nil { 663 log.Printf("Error reading response body: %v", err) 664 return 665 } 666 667 var result types.RepoBlobResponse 668 err = json.Unmarshal(body, &result) 669 if err != nil { 670 log.Println("failed to parse response:", err) 671 return 672 } 673 674 var breadcrumbs [][]string 675 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 676 if filePath != "" { 677 for idx, elem := range strings.Split(filePath, "/") { 678 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 679 } 680 } 681 682 showRendered := false 683 renderToggle := false 684 685 if markup.GetFormat(result.Path) == markup.FormatMarkdown { 686 renderToggle = true 687 showRendered = r.URL.Query().Get("code") != "true" 688 } 689 690 user := s.oauth.GetUser(r) 691 s.pages.RepoBlob(w, pages.RepoBlobParams{ 692 LoggedInUser: user, 693 RepoInfo: f.RepoInfo(s, user), 694 RepoBlobResponse: result, 695 BreadCrumbs: breadcrumbs, 696 ShowRendered: showRendered, 697 RenderToggle: renderToggle, 698 }) 699 return 700} 701 702func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 703 f, err := s.fullyResolvedRepo(r) 704 if err != nil { 705 log.Println("failed to get repo and knot", err) 706 return 707 } 708 709 ref := chi.URLParam(r, "ref") 710 filePath := chi.URLParam(r, "*") 711 712 protocol := "http" 713 if !s.config.Core.Dev { 714 protocol = "https" 715 } 716 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 717 if err != nil { 718 log.Println("failed to reach knotserver", err) 719 return 720 } 721 722 body, err := io.ReadAll(resp.Body) 723 if err != nil { 724 log.Printf("Error reading response body: %v", err) 725 return 726 } 727 728 var result types.RepoBlobResponse 729 err = json.Unmarshal(body, &result) 730 if err != nil { 731 log.Println("failed to parse response:", err) 732 return 733 } 734 735 if result.IsBinary { 736 w.Header().Set("Content-Type", "application/octet-stream") 737 w.Write(body) 738 return 739 } 740 741 w.Header().Set("Content-Type", "text/plain") 742 w.Write([]byte(result.Contents)) 743 return 744} 745 746func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 747 f, err := s.fullyResolvedRepo(r) 748 if err != nil { 749 log.Println("failed to get repo and knot", err) 750 return 751 } 752 753 collaborator := r.FormValue("collaborator") 754 if collaborator == "" { 755 http.Error(w, "malformed form", http.StatusBadRequest) 756 return 757 } 758 759 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 760 if err != nil { 761 w.Write([]byte("failed to resolve collaborator did to a handle")) 762 return 763 } 764 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 765 766 // TODO: create an atproto record for this 767 768 secret, err := db.GetRegistrationKey(s.db, f.Knot) 769 if err != nil { 770 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 771 return 772 } 773 774 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 775 if err != nil { 776 log.Println("failed to create client to ", f.Knot) 777 return 778 } 779 780 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 781 if err != nil { 782 log.Printf("failed to make request to %s: %s", f.Knot, err) 783 return 784 } 785 786 if ksResp.StatusCode != http.StatusNoContent { 787 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 788 return 789 } 790 791 tx, err := s.db.BeginTx(r.Context(), nil) 792 if err != nil { 793 log.Println("failed to start tx") 794 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 795 return 796 } 797 defer func() { 798 tx.Rollback() 799 err = s.enforcer.E.LoadPolicy() 800 if err != nil { 801 log.Println("failed to rollback policies") 802 } 803 }() 804 805 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 806 if err != nil { 807 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 808 return 809 } 810 811 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 812 if err != nil { 813 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 814 return 815 } 816 817 err = tx.Commit() 818 if err != nil { 819 log.Println("failed to commit changes", err) 820 http.Error(w, err.Error(), http.StatusInternalServerError) 821 return 822 } 823 824 err = s.enforcer.E.SavePolicy() 825 if err != nil { 826 log.Println("failed to update ACLs", err) 827 http.Error(w, err.Error(), http.StatusInternalServerError) 828 return 829 } 830 831 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 832 833} 834 835func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { 836 user := s.oauth.GetUser(r) 837 838 f, err := s.fullyResolvedRepo(r) 839 if err != nil { 840 log.Println("failed to get repo and knot", err) 841 return 842 } 843 844 // remove record from pds 845 xrpcClient, err := s.oauth.AuthorizedClient(r) 846 if err != nil { 847 log.Println("failed to get authorized client", err) 848 return 849 } 850 repoRkey := f.RepoAt.RecordKey().String() 851 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 852 Collection: tangled.RepoNSID, 853 Repo: user.Did, 854 Rkey: repoRkey, 855 }) 856 if err != nil { 857 log.Printf("failed to delete record: %s", err) 858 s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 859 return 860 } 861 log.Println("removed repo record ", f.RepoAt.String()) 862 863 secret, err := db.GetRegistrationKey(s.db, f.Knot) 864 if err != nil { 865 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 866 return 867 } 868 869 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 870 if err != nil { 871 log.Println("failed to create client to ", f.Knot) 872 return 873 } 874 875 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 876 if err != nil { 877 log.Printf("failed to make request to %s: %s", f.Knot, err) 878 return 879 } 880 881 if ksResp.StatusCode != http.StatusNoContent { 882 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 883 } else { 884 log.Println("removed repo from knot ", f.Knot) 885 } 886 887 tx, err := s.db.BeginTx(r.Context(), nil) 888 if err != nil { 889 log.Println("failed to start tx") 890 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 891 return 892 } 893 defer func() { 894 tx.Rollback() 895 err = s.enforcer.E.LoadPolicy() 896 if err != nil { 897 log.Println("failed to rollback policies") 898 } 899 }() 900 901 // remove collaborator RBAC 902 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 903 if err != nil { 904 s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 905 return 906 } 907 for _, c := range repoCollaborators { 908 did := c[0] 909 s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 910 } 911 log.Println("removed collaborators") 912 913 // remove repo RBAC 914 err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 915 if err != nil { 916 s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 917 return 918 } 919 920 // remove repo from db 921 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 922 if err != nil { 923 s.pages.Notice(w, "settings-delete", "Failed to update appview") 924 return 925 } 926 log.Println("removed repo from db") 927 928 err = tx.Commit() 929 if err != nil { 930 log.Println("failed to commit changes", err) 931 http.Error(w, err.Error(), http.StatusInternalServerError) 932 return 933 } 934 935 err = s.enforcer.E.SavePolicy() 936 if err != nil { 937 log.Println("failed to update ACLs", err) 938 http.Error(w, err.Error(), http.StatusInternalServerError) 939 return 940 } 941 942 s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 943} 944 945func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 946 f, err := s.fullyResolvedRepo(r) 947 if err != nil { 948 log.Println("failed to get repo and knot", err) 949 return 950 } 951 952 branch := r.FormValue("branch") 953 if branch == "" { 954 http.Error(w, "malformed form", http.StatusBadRequest) 955 return 956 } 957 958 secret, err := db.GetRegistrationKey(s.db, f.Knot) 959 if err != nil { 960 log.Printf("no key found for domain %s: %s\n", f.Knot, err) 961 return 962 } 963 964 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 965 if err != nil { 966 log.Println("failed to create client to ", f.Knot) 967 return 968 } 969 970 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 971 if err != nil { 972 log.Printf("failed to make request to %s: %s", f.Knot, err) 973 return 974 } 975 976 if ksResp.StatusCode != http.StatusNoContent { 977 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 978 return 979 } 980 981 w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 982} 983 984func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 985 f, err := s.fullyResolvedRepo(r) 986 if err != nil { 987 log.Println("failed to get repo and knot", err) 988 return 989 } 990 991 switch r.Method { 992 case http.MethodGet: 993 // for now, this is just pubkeys 994 user := s.oauth.GetUser(r) 995 repoCollaborators, err := f.Collaborators(r.Context(), s) 996 if err != nil { 997 log.Println("failed to get collaborators", err) 998 } 999 1000 isCollaboratorInviteAllowed := false 1001 if user != nil { 1002 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 1003 if err == nil && ok { 1004 isCollaboratorInviteAllowed = true 1005 } 1006 } 1007 1008 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1009 if err != nil { 1010 log.Println("failed to create unsigned client", err) 1011 return 1012 } 1013 1014 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1015 if err != nil { 1016 log.Println("failed to reach knotserver", err) 1017 return 1018 } 1019 defer resp.Body.Close() 1020 1021 body, err := io.ReadAll(resp.Body) 1022 if err != nil { 1023 log.Printf("Error reading response body: %v", err) 1024 } 1025 1026 var result types.RepoBranchesResponse 1027 err = json.Unmarshal(body, &result) 1028 if err != nil { 1029 log.Println("failed to parse response:", err) 1030 } 1031 1032 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 1033 LoggedInUser: user, 1034 RepoInfo: f.RepoInfo(s, user), 1035 Collaborators: repoCollaborators, 1036 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1037 Branches: result.Branches, 1038 }) 1039 } 1040} 1041 1042type FullyResolvedRepo struct { 1043 Knot string 1044 OwnerId identity.Identity 1045 RepoName string 1046 RepoAt syntax.ATURI 1047 Description string 1048 CreatedAt string 1049 Ref string 1050 CurrentDir string 1051} 1052 1053func (f *FullyResolvedRepo) OwnerDid() string { 1054 return f.OwnerId.DID.String() 1055} 1056 1057func (f *FullyResolvedRepo) OwnerHandle() string { 1058 return f.OwnerId.Handle.String() 1059} 1060 1061func (f *FullyResolvedRepo) OwnerSlashRepo() string { 1062 handle := f.OwnerId.Handle 1063 1064 var p string 1065 if handle != "" && !handle.IsInvalidHandle() { 1066 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 1067 } else { 1068 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1069 } 1070 1071 return p 1072} 1073 1074func (f *FullyResolvedRepo) DidSlashRepo() string { 1075 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1076 return p 1077} 1078 1079func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 1080 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1081 if err != nil { 1082 return nil, err 1083 } 1084 1085 var collaborators []pages.Collaborator 1086 for _, item := range repoCollaborators { 1087 // currently only two roles: owner and member 1088 var role string 1089 if item[3] == "repo:owner" { 1090 role = "owner" 1091 } else if item[3] == "repo:collaborator" { 1092 role = "collaborator" 1093 } else { 1094 continue 1095 } 1096 1097 did := item[0] 1098 1099 c := pages.Collaborator{ 1100 Did: did, 1101 Handle: "", 1102 Role: role, 1103 } 1104 collaborators = append(collaborators, c) 1105 } 1106 1107 // populate all collborators with handles 1108 identsToResolve := make([]string, len(collaborators)) 1109 for i, collab := range collaborators { 1110 identsToResolve[i] = collab.Did 1111 } 1112 1113 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 1114 for i, resolved := range resolvedIdents { 1115 if resolved != nil { 1116 collaborators[i].Handle = resolved.Handle.String() 1117 } 1118 } 1119 1120 return collaborators, nil 1121} 1122 1123func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo { 1124 isStarred := false 1125 if u != nil { 1126 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 1127 } 1128 1129 starCount, err := db.GetStarCount(s.db, f.RepoAt) 1130 if err != nil { 1131 log.Println("failed to get star count for ", f.RepoAt) 1132 } 1133 issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 1134 if err != nil { 1135 log.Println("failed to get issue count for ", f.RepoAt) 1136 } 1137 pullCount, err := db.GetPullCount(s.db, f.RepoAt) 1138 if err != nil { 1139 log.Println("failed to get issue count for ", f.RepoAt) 1140 } 1141 source, err := db.GetRepoSource(s.db, f.RepoAt) 1142 if errors.Is(err, sql.ErrNoRows) { 1143 source = "" 1144 } else if err != nil { 1145 log.Println("failed to get repo source for ", f.RepoAt, err) 1146 } 1147 1148 var sourceRepo *db.Repo 1149 if source != "" { 1150 sourceRepo, err = db.GetRepoByAtUri(s.db, source) 1151 if err != nil { 1152 log.Println("failed to get repo by at uri", err) 1153 } 1154 } 1155 1156 var sourceHandle *identity.Identity 1157 if sourceRepo != nil { 1158 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 1159 if err != nil { 1160 log.Println("failed to resolve source repo", err) 1161 } 1162 } 1163 1164 knot := f.Knot 1165 var disableFork bool 1166 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 1167 if err != nil { 1168 log.Printf("failed to create unsigned client for %s: %v", knot, err) 1169 } else { 1170 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1171 if err != nil { 1172 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 1173 } else { 1174 defer resp.Body.Close() 1175 body, err := io.ReadAll(resp.Body) 1176 if err != nil { 1177 log.Printf("error reading branch response body: %v", err) 1178 } else { 1179 var branchesResp types.RepoBranchesResponse 1180 if err := json.Unmarshal(body, &branchesResp); err != nil { 1181 log.Printf("error parsing branch response: %v", err) 1182 } else { 1183 disableFork = false 1184 } 1185 1186 if len(branchesResp.Branches) == 0 { 1187 disableFork = true 1188 } 1189 } 1190 } 1191 } 1192 1193 repoInfo := repoinfo.RepoInfo{ 1194 OwnerDid: f.OwnerDid(), 1195 OwnerHandle: f.OwnerHandle(), 1196 Name: f.RepoName, 1197 RepoAt: f.RepoAt, 1198 Description: f.Description, 1199 Ref: f.Ref, 1200 IsStarred: isStarred, 1201 Knot: knot, 1202 Roles: RolesInRepo(s, u, f), 1203 Stats: db.RepoStats{ 1204 StarCount: starCount, 1205 IssueCount: issueCount, 1206 PullCount: pullCount, 1207 }, 1208 DisableFork: disableFork, 1209 CurrentDir: f.CurrentDir, 1210 } 1211 1212 if sourceRepo != nil { 1213 repoInfo.Source = sourceRepo 1214 repoInfo.SourceHandle = sourceHandle.Handle.String() 1215 } 1216 1217 return repoInfo 1218} 1219 1220func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1221 user := s.oauth.GetUser(r) 1222 f, err := s.fullyResolvedRepo(r) 1223 if err != nil { 1224 log.Println("failed to get repo and knot", err) 1225 return 1226 } 1227 1228 issueId := chi.URLParam(r, "issue") 1229 issueIdInt, err := strconv.Atoi(issueId) 1230 if err != nil { 1231 http.Error(w, "bad issue id", http.StatusBadRequest) 1232 log.Println("failed to parse issue id", err) 1233 return 1234 } 1235 1236 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 1237 if err != nil { 1238 log.Println("failed to get issue and comments", err) 1239 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1240 return 1241 } 1242 1243 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 1244 if err != nil { 1245 log.Println("failed to resolve issue owner", err) 1246 } 1247 1248 identsToResolve := make([]string, len(comments)) 1249 for i, comment := range comments { 1250 identsToResolve[i] = comment.OwnerDid 1251 } 1252 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1253 didHandleMap := make(map[string]string) 1254 for _, identity := range resolvedIds { 1255 if !identity.Handle.IsInvalidHandle() { 1256 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1257 } else { 1258 didHandleMap[identity.DID.String()] = identity.DID.String() 1259 } 1260 } 1261 1262 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1263 LoggedInUser: user, 1264 RepoInfo: f.RepoInfo(s, user), 1265 Issue: *issue, 1266 Comments: comments, 1267 1268 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1269 DidHandleMap: didHandleMap, 1270 }) 1271 1272} 1273 1274func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1275 user := s.oauth.GetUser(r) 1276 f, err := s.fullyResolvedRepo(r) 1277 if err != nil { 1278 log.Println("failed to get repo and knot", err) 1279 return 1280 } 1281 1282 issueId := chi.URLParam(r, "issue") 1283 issueIdInt, err := strconv.Atoi(issueId) 1284 if err != nil { 1285 http.Error(w, "bad issue id", http.StatusBadRequest) 1286 log.Println("failed to parse issue id", err) 1287 return 1288 } 1289 1290 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1291 if err != nil { 1292 log.Println("failed to get issue", err) 1293 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1294 return 1295 } 1296 1297 collaborators, err := f.Collaborators(r.Context(), s) 1298 if err != nil { 1299 log.Println("failed to fetch repo collaborators: %w", err) 1300 } 1301 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1302 return user.Did == collab.Did 1303 }) 1304 isIssueOwner := user.Did == issue.OwnerDid 1305 1306 // TODO: make this more granular 1307 if isIssueOwner || isCollaborator { 1308 1309 closed := tangled.RepoIssueStateClosed 1310 1311 client, err := s.oauth.AuthorizedClient(r) 1312 if err != nil { 1313 log.Println("failed to get authorized client", err) 1314 return 1315 } 1316 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1317 Collection: tangled.RepoIssueStateNSID, 1318 Repo: user.Did, 1319 Rkey: appview.TID(), 1320 Record: &lexutil.LexiconTypeDecoder{ 1321 Val: &tangled.RepoIssueState{ 1322 Issue: issue.IssueAt, 1323 State: closed, 1324 }, 1325 }, 1326 }) 1327 1328 if err != nil { 1329 log.Println("failed to update issue state", err) 1330 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1331 return 1332 } 1333 1334 err = db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1335 if err != nil { 1336 log.Println("failed to close issue", err) 1337 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1338 return 1339 } 1340 1341 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1342 return 1343 } else { 1344 log.Println("user is not permitted to close issue") 1345 http.Error(w, "for biden", http.StatusUnauthorized) 1346 return 1347 } 1348} 1349 1350func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1351 user := s.oauth.GetUser(r) 1352 f, err := s.fullyResolvedRepo(r) 1353 if err != nil { 1354 log.Println("failed to get repo and knot", err) 1355 return 1356 } 1357 1358 issueId := chi.URLParam(r, "issue") 1359 issueIdInt, err := strconv.Atoi(issueId) 1360 if err != nil { 1361 http.Error(w, "bad issue id", http.StatusBadRequest) 1362 log.Println("failed to parse issue id", err) 1363 return 1364 } 1365 1366 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1367 if err != nil { 1368 log.Println("failed to get issue", err) 1369 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1370 return 1371 } 1372 1373 collaborators, err := f.Collaborators(r.Context(), s) 1374 if err != nil { 1375 log.Println("failed to fetch repo collaborators: %w", err) 1376 } 1377 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1378 return user.Did == collab.Did 1379 }) 1380 isIssueOwner := user.Did == issue.OwnerDid 1381 1382 if isCollaborator || isIssueOwner { 1383 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1384 if err != nil { 1385 log.Println("failed to reopen issue", err) 1386 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1387 return 1388 } 1389 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1390 return 1391 } else { 1392 log.Println("user is not the owner of the repo") 1393 http.Error(w, "forbidden", http.StatusUnauthorized) 1394 return 1395 } 1396} 1397 1398func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1399 user := s.oauth.GetUser(r) 1400 f, err := s.fullyResolvedRepo(r) 1401 if err != nil { 1402 log.Println("failed to get repo and knot", err) 1403 return 1404 } 1405 1406 issueId := chi.URLParam(r, "issue") 1407 issueIdInt, err := strconv.Atoi(issueId) 1408 if err != nil { 1409 http.Error(w, "bad issue id", http.StatusBadRequest) 1410 log.Println("failed to parse issue id", err) 1411 return 1412 } 1413 1414 switch r.Method { 1415 case http.MethodPost: 1416 body := r.FormValue("body") 1417 if body == "" { 1418 s.pages.Notice(w, "issue", "Body is required") 1419 return 1420 } 1421 1422 commentId := mathrand.IntN(1000000) 1423 rkey := appview.TID() 1424 1425 err := db.NewIssueComment(s.db, &db.Comment{ 1426 OwnerDid: user.Did, 1427 RepoAt: f.RepoAt, 1428 Issue: issueIdInt, 1429 CommentId: commentId, 1430 Body: body, 1431 Rkey: rkey, 1432 }) 1433 if err != nil { 1434 log.Println("failed to create comment", err) 1435 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1436 return 1437 } 1438 1439 createdAt := time.Now().Format(time.RFC3339) 1440 commentIdInt64 := int64(commentId) 1441 ownerDid := user.Did 1442 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1443 if err != nil { 1444 log.Println("failed to get issue at", err) 1445 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1446 return 1447 } 1448 1449 atUri := f.RepoAt.String() 1450 client, err := s.oauth.AuthorizedClient(r) 1451 if err != nil { 1452 log.Println("failed to get authorized client", err) 1453 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1454 return 1455 } 1456 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1457 Collection: tangled.RepoIssueCommentNSID, 1458 Repo: user.Did, 1459 Rkey: rkey, 1460 Record: &lexutil.LexiconTypeDecoder{ 1461 Val: &tangled.RepoIssueComment{ 1462 Repo: &atUri, 1463 Issue: issueAt, 1464 CommentId: &commentIdInt64, 1465 Owner: &ownerDid, 1466 Body: body, 1467 CreatedAt: createdAt, 1468 }, 1469 }, 1470 }) 1471 if err != nil { 1472 log.Println("failed to create comment", err) 1473 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1474 return 1475 } 1476 1477 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1478 return 1479 } 1480} 1481 1482func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1483 user := s.oauth.GetUser(r) 1484 f, err := s.fullyResolvedRepo(r) 1485 if err != nil { 1486 log.Println("failed to get repo and knot", err) 1487 return 1488 } 1489 1490 issueId := chi.URLParam(r, "issue") 1491 issueIdInt, err := strconv.Atoi(issueId) 1492 if err != nil { 1493 http.Error(w, "bad issue id", http.StatusBadRequest) 1494 log.Println("failed to parse issue id", err) 1495 return 1496 } 1497 1498 commentId := chi.URLParam(r, "comment_id") 1499 commentIdInt, err := strconv.Atoi(commentId) 1500 if err != nil { 1501 http.Error(w, "bad comment id", http.StatusBadRequest) 1502 log.Println("failed to parse issue id", err) 1503 return 1504 } 1505 1506 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1507 if err != nil { 1508 log.Println("failed to get issue", err) 1509 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1510 return 1511 } 1512 1513 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1514 if err != nil { 1515 http.Error(w, "bad comment id", http.StatusBadRequest) 1516 return 1517 } 1518 1519 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1520 if err != nil { 1521 log.Println("failed to resolve did") 1522 return 1523 } 1524 1525 didHandleMap := make(map[string]string) 1526 if !identity.Handle.IsInvalidHandle() { 1527 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1528 } else { 1529 didHandleMap[identity.DID.String()] = identity.DID.String() 1530 } 1531 1532 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1533 LoggedInUser: user, 1534 RepoInfo: f.RepoInfo(s, user), 1535 DidHandleMap: didHandleMap, 1536 Issue: issue, 1537 Comment: comment, 1538 }) 1539} 1540 1541func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1542 user := s.oauth.GetUser(r) 1543 f, err := s.fullyResolvedRepo(r) 1544 if err != nil { 1545 log.Println("failed to get repo and knot", err) 1546 return 1547 } 1548 1549 issueId := chi.URLParam(r, "issue") 1550 issueIdInt, err := strconv.Atoi(issueId) 1551 if err != nil { 1552 http.Error(w, "bad issue id", http.StatusBadRequest) 1553 log.Println("failed to parse issue id", err) 1554 return 1555 } 1556 1557 commentId := chi.URLParam(r, "comment_id") 1558 commentIdInt, err := strconv.Atoi(commentId) 1559 if err != nil { 1560 http.Error(w, "bad comment id", http.StatusBadRequest) 1561 log.Println("failed to parse issue id", err) 1562 return 1563 } 1564 1565 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1566 if err != nil { 1567 log.Println("failed to get issue", err) 1568 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1569 return 1570 } 1571 1572 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1573 if err != nil { 1574 http.Error(w, "bad comment id", http.StatusBadRequest) 1575 return 1576 } 1577 1578 if comment.OwnerDid != user.Did { 1579 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1580 return 1581 } 1582 1583 switch r.Method { 1584 case http.MethodGet: 1585 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1586 LoggedInUser: user, 1587 RepoInfo: f.RepoInfo(s, user), 1588 Issue: issue, 1589 Comment: comment, 1590 }) 1591 case http.MethodPost: 1592 // extract form value 1593 newBody := r.FormValue("body") 1594 client, err := s.oauth.AuthorizedClient(r) 1595 if err != nil { 1596 log.Println("failed to get authorized client", err) 1597 s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1598 return 1599 } 1600 rkey := comment.Rkey 1601 1602 // optimistic update 1603 edited := time.Now() 1604 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1605 if err != nil { 1606 log.Println("failed to perferom update-description query", err) 1607 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1608 return 1609 } 1610 1611 // rkey is optional, it was introduced later 1612 if comment.Rkey != "" { 1613 // update the record on pds 1614 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1615 if err != nil { 1616 // failed to get record 1617 log.Println(err, rkey) 1618 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1619 return 1620 } 1621 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1622 record, _ := data.UnmarshalJSON(value) 1623 1624 repoAt := record["repo"].(string) 1625 issueAt := record["issue"].(string) 1626 createdAt := record["createdAt"].(string) 1627 commentIdInt64 := int64(commentIdInt) 1628 1629 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1630 Collection: tangled.RepoIssueCommentNSID, 1631 Repo: user.Did, 1632 Rkey: rkey, 1633 SwapRecord: ex.Cid, 1634 Record: &lexutil.LexiconTypeDecoder{ 1635 Val: &tangled.RepoIssueComment{ 1636 Repo: &repoAt, 1637 Issue: issueAt, 1638 CommentId: &commentIdInt64, 1639 Owner: &comment.OwnerDid, 1640 Body: newBody, 1641 CreatedAt: createdAt, 1642 }, 1643 }, 1644 }) 1645 if err != nil { 1646 log.Println(err) 1647 } 1648 } 1649 1650 // optimistic update for htmx 1651 didHandleMap := map[string]string{ 1652 user.Did: user.Handle, 1653 } 1654 comment.Body = newBody 1655 comment.Edited = &edited 1656 1657 // return new comment body with htmx 1658 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1659 LoggedInUser: user, 1660 RepoInfo: f.RepoInfo(s, user), 1661 DidHandleMap: didHandleMap, 1662 Issue: issue, 1663 Comment: comment, 1664 }) 1665 return 1666 1667 } 1668 1669} 1670 1671func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1672 user := s.oauth.GetUser(r) 1673 f, err := s.fullyResolvedRepo(r) 1674 if err != nil { 1675 log.Println("failed to get repo and knot", err) 1676 return 1677 } 1678 1679 issueId := chi.URLParam(r, "issue") 1680 issueIdInt, err := strconv.Atoi(issueId) 1681 if err != nil { 1682 http.Error(w, "bad issue id", http.StatusBadRequest) 1683 log.Println("failed to parse issue id", err) 1684 return 1685 } 1686 1687 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1688 if err != nil { 1689 log.Println("failed to get issue", err) 1690 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1691 return 1692 } 1693 1694 commentId := chi.URLParam(r, "comment_id") 1695 commentIdInt, err := strconv.Atoi(commentId) 1696 if err != nil { 1697 http.Error(w, "bad comment id", http.StatusBadRequest) 1698 log.Println("failed to parse issue id", err) 1699 return 1700 } 1701 1702 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1703 if err != nil { 1704 http.Error(w, "bad comment id", http.StatusBadRequest) 1705 return 1706 } 1707 1708 if comment.OwnerDid != user.Did { 1709 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1710 return 1711 } 1712 1713 if comment.Deleted != nil { 1714 http.Error(w, "comment already deleted", http.StatusBadRequest) 1715 return 1716 } 1717 1718 // optimistic deletion 1719 deleted := time.Now() 1720 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1721 if err != nil { 1722 log.Println("failed to delete comment") 1723 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1724 return 1725 } 1726 1727 // delete from pds 1728 if comment.Rkey != "" { 1729 client, err := s.oauth.AuthorizedClient(r) 1730 if err != nil { 1731 log.Println("failed to get authorized client", err) 1732 s.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1733 return 1734 } 1735 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1736 Collection: tangled.GraphFollowNSID, 1737 Repo: user.Did, 1738 Rkey: comment.Rkey, 1739 }) 1740 if err != nil { 1741 log.Println(err) 1742 } 1743 } 1744 1745 // optimistic update for htmx 1746 didHandleMap := map[string]string{ 1747 user.Did: user.Handle, 1748 } 1749 comment.Body = "" 1750 comment.Deleted = &deleted 1751 1752 // htmx fragment of comment after deletion 1753 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1754 LoggedInUser: user, 1755 RepoInfo: f.RepoInfo(s, user), 1756 DidHandleMap: didHandleMap, 1757 Issue: issue, 1758 Comment: comment, 1759 }) 1760 return 1761} 1762 1763func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1764 params := r.URL.Query() 1765 state := params.Get("state") 1766 isOpen := true 1767 switch state { 1768 case "open": 1769 isOpen = true 1770 case "closed": 1771 isOpen = false 1772 default: 1773 isOpen = true 1774 } 1775 1776 page, ok := r.Context().Value("page").(pagination.Page) 1777 if !ok { 1778 log.Println("failed to get page") 1779 page = pagination.FirstPage() 1780 } 1781 1782 user := s.oauth.GetUser(r) 1783 f, err := s.fullyResolvedRepo(r) 1784 if err != nil { 1785 log.Println("failed to get repo and knot", err) 1786 return 1787 } 1788 1789 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1790 if err != nil { 1791 log.Println("failed to get issues", err) 1792 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1793 return 1794 } 1795 1796 identsToResolve := make([]string, len(issues)) 1797 for i, issue := range issues { 1798 identsToResolve[i] = issue.OwnerDid 1799 } 1800 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1801 didHandleMap := make(map[string]string) 1802 for _, identity := range resolvedIds { 1803 if !identity.Handle.IsInvalidHandle() { 1804 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1805 } else { 1806 didHandleMap[identity.DID.String()] = identity.DID.String() 1807 } 1808 } 1809 1810 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1811 LoggedInUser: s.oauth.GetUser(r), 1812 RepoInfo: f.RepoInfo(s, user), 1813 Issues: issues, 1814 DidHandleMap: didHandleMap, 1815 FilteringByOpen: isOpen, 1816 Page: page, 1817 }) 1818 return 1819} 1820 1821func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1822 user := s.oauth.GetUser(r) 1823 1824 f, err := s.fullyResolvedRepo(r) 1825 if err != nil { 1826 log.Println("failed to get repo and knot", err) 1827 return 1828 } 1829 1830 switch r.Method { 1831 case http.MethodGet: 1832 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1833 LoggedInUser: user, 1834 RepoInfo: f.RepoInfo(s, user), 1835 }) 1836 case http.MethodPost: 1837 title := r.FormValue("title") 1838 body := r.FormValue("body") 1839 1840 if title == "" || body == "" { 1841 s.pages.Notice(w, "issues", "Title and body are required") 1842 return 1843 } 1844 1845 tx, err := s.db.BeginTx(r.Context(), nil) 1846 if err != nil { 1847 s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1848 return 1849 } 1850 1851 err = db.NewIssue(tx, &db.Issue{ 1852 RepoAt: f.RepoAt, 1853 Title: title, 1854 Body: body, 1855 OwnerDid: user.Did, 1856 }) 1857 if err != nil { 1858 log.Println("failed to create issue", err) 1859 s.pages.Notice(w, "issues", "Failed to create issue.") 1860 return 1861 } 1862 1863 issueId, err := db.GetIssueId(s.db, f.RepoAt) 1864 if err != nil { 1865 log.Println("failed to get issue id", err) 1866 s.pages.Notice(w, "issues", "Failed to create issue.") 1867 return 1868 } 1869 1870 client, err := s.oauth.AuthorizedClient(r) 1871 if err != nil { 1872 log.Println("failed to get authorized client", err) 1873 s.pages.Notice(w, "issues", "Failed to create issue.") 1874 return 1875 } 1876 atUri := f.RepoAt.String() 1877 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1878 Collection: tangled.RepoIssueNSID, 1879 Repo: user.Did, 1880 Rkey: appview.TID(), 1881 Record: &lexutil.LexiconTypeDecoder{ 1882 Val: &tangled.RepoIssue{ 1883 Repo: atUri, 1884 Title: title, 1885 Body: &body, 1886 Owner: user.Did, 1887 IssueId: int64(issueId), 1888 }, 1889 }, 1890 }) 1891 if err != nil { 1892 log.Println("failed to create issue", err) 1893 s.pages.Notice(w, "issues", "Failed to create issue.") 1894 return 1895 } 1896 1897 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1898 if err != nil { 1899 log.Println("failed to set issue at", err) 1900 s.pages.Notice(w, "issues", "Failed to create issue.") 1901 return 1902 } 1903 1904 if !s.config.Core.Dev { 1905 err = s.posthog.Enqueue(posthog.Capture{ 1906 DistinctId: user.Did, 1907 Event: "new_issue", 1908 Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 1909 }) 1910 if err != nil { 1911 log.Println("failed to enqueue posthog event:", err) 1912 } 1913 } 1914 1915 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1916 return 1917 } 1918} 1919 1920func (s *State) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1921 user := s.oauth.GetUser(r) 1922 f, err := s.fullyResolvedRepo(r) 1923 if err != nil { 1924 log.Printf("failed to resolve source repo: %v", err) 1925 return 1926 } 1927 1928 switch r.Method { 1929 case http.MethodPost: 1930 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1931 if err != nil { 1932 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1933 return 1934 } 1935 1936 client, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1937 if err != nil { 1938 s.pages.Notice(w, "repo", "Failed to reach knot server.") 1939 return 1940 } 1941 1942 var uri string 1943 if s.config.Core.Dev { 1944 uri = "http" 1945 } else { 1946 uri = "https" 1947 } 1948 forkName := fmt.Sprintf("%s", f.RepoName) 1949 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1950 1951 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1952 if err != nil { 1953 s.pages.Notice(w, "repo", "Failed to sync repository fork.") 1954 return 1955 } 1956 1957 s.pages.HxRefresh(w) 1958 return 1959 } 1960} 1961 1962func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1963 user := s.oauth.GetUser(r) 1964 f, err := s.fullyResolvedRepo(r) 1965 if err != nil { 1966 log.Printf("failed to resolve source repo: %v", err) 1967 return 1968 } 1969 1970 switch r.Method { 1971 case http.MethodGet: 1972 user := s.oauth.GetUser(r) 1973 knots, err := s.enforcer.GetDomainsForUser(user.Did) 1974 if err != nil { 1975 s.pages.Notice(w, "repo", "Invalid user account.") 1976 return 1977 } 1978 1979 s.pages.ForkRepo(w, pages.ForkRepoParams{ 1980 LoggedInUser: user, 1981 Knots: knots, 1982 RepoInfo: f.RepoInfo(s, user), 1983 }) 1984 1985 case http.MethodPost: 1986 1987 knot := r.FormValue("knot") 1988 if knot == "" { 1989 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.") 1990 return 1991 } 1992 1993 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1994 if err != nil || !ok { 1995 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1996 return 1997 } 1998 1999 forkName := fmt.Sprintf("%s", f.RepoName) 2000 2001 // this check is *only* to see if the forked repo name already exists 2002 // in the user's account. 2003 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 2004 if err != nil { 2005 if errors.Is(err, sql.ErrNoRows) { 2006 // no existing repo with this name found, we can use the name as is 2007 } else { 2008 log.Println("error fetching existing repo from db", err) 2009 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 2010 return 2011 } 2012 } else if existingRepo != nil { 2013 // repo with this name already exists, append random string 2014 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 2015 } 2016 secret, err := db.GetRegistrationKey(s.db, knot) 2017 if err != nil { 2018 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 2019 return 2020 } 2021 2022 client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev) 2023 if err != nil { 2024 s.pages.Notice(w, "repo", "Failed to reach knot server.") 2025 return 2026 } 2027 2028 var uri string 2029 if s.config.Core.Dev { 2030 uri = "http" 2031 } else { 2032 uri = "https" 2033 } 2034 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 2035 sourceAt := f.RepoAt.String() 2036 2037 rkey := appview.TID() 2038 repo := &db.Repo{ 2039 Did: user.Did, 2040 Name: forkName, 2041 Knot: knot, 2042 Rkey: rkey, 2043 Source: sourceAt, 2044 } 2045 2046 tx, err := s.db.BeginTx(r.Context(), nil) 2047 if err != nil { 2048 log.Println(err) 2049 s.pages.Notice(w, "repo", "Failed to save repository information.") 2050 return 2051 } 2052 defer func() { 2053 tx.Rollback() 2054 err = s.enforcer.E.LoadPolicy() 2055 if err != nil { 2056 log.Println("failed to rollback policies") 2057 } 2058 }() 2059 2060 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 2061 if err != nil { 2062 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 2063 return 2064 } 2065 2066 switch resp.StatusCode { 2067 case http.StatusConflict: 2068 s.pages.Notice(w, "repo", "A repository with that name already exists.") 2069 return 2070 case http.StatusInternalServerError: 2071 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 2072 case http.StatusNoContent: 2073 // continue 2074 } 2075 2076 xrpcClient, err := s.oauth.AuthorizedClient(r) 2077 if err != nil { 2078 log.Println("failed to get authorized client", err) 2079 s.pages.Notice(w, "repo", "Failed to create repository.") 2080 return 2081 } 2082 2083 createdAt := time.Now().Format(time.RFC3339) 2084 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2085 Collection: tangled.RepoNSID, 2086 Repo: user.Did, 2087 Rkey: rkey, 2088 Record: &lexutil.LexiconTypeDecoder{ 2089 Val: &tangled.Repo{ 2090 Knot: repo.Knot, 2091 Name: repo.Name, 2092 CreatedAt: createdAt, 2093 Owner: user.Did, 2094 Source: &sourceAt, 2095 }}, 2096 }) 2097 if err != nil { 2098 log.Printf("failed to create record: %s", err) 2099 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 2100 return 2101 } 2102 log.Println("created repo record: ", atresp.Uri) 2103 2104 repo.AtUri = atresp.Uri 2105 err = db.AddRepo(tx, repo) 2106 if err != nil { 2107 log.Println(err) 2108 s.pages.Notice(w, "repo", "Failed to save repository information.") 2109 return 2110 } 2111 2112 // acls 2113 p, _ := securejoin.SecureJoin(user.Did, forkName) 2114 err = s.enforcer.AddRepo(user.Did, knot, p) 2115 if err != nil { 2116 log.Println(err) 2117 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2118 return 2119 } 2120 2121 err = tx.Commit() 2122 if err != nil { 2123 log.Println("failed to commit changes", err) 2124 http.Error(w, err.Error(), http.StatusInternalServerError) 2125 return 2126 } 2127 2128 err = s.enforcer.E.SavePolicy() 2129 if err != nil { 2130 log.Println("failed to update ACLs", err) 2131 http.Error(w, err.Error(), http.StatusInternalServerError) 2132 return 2133 } 2134 2135 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2136 return 2137 } 2138}