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