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